@momo-kits/native-kits 0.167.1-beta.9 → 0.167.1-duo-search.1-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 (196) hide show
  1. package/CLAUDE.md +36 -0
  2. package/build.gradle.kts +11 -0
  3. package/compose/build.gradle.kts +204 -0
  4. package/compose/build.gradle.kts.backup +204 -0
  5. package/compose/compose.podspec +47 -0
  6. package/compose/src/androidMain/kotlin/vn/momo/kits/const/HostFont.android.kt +19 -0
  7. package/compose/src/androidMain/kotlin/vn/momo/kits/navigation/ScrollToTop.android.kt +6 -0
  8. package/compose/src/androidMain/kotlin/vn/momo/kits/platform/BackGesture.android.kt +12 -0
  9. package/compose/src/androidMain/kotlin/vn/momo/kits/platform/OsFontScale.android.kt +7 -0
  10. package/compose/src/androidMain/kotlin/vn/momo/kits/platform/Platform.android.kt +153 -0
  11. package/compose/src/androidMain/kotlin/vn/momo/kits/platform/ReservedRegions.android.kt +6 -0
  12. package/compose/src/commonMain/kotlin/vn/momo/kits/application/AnimationSearchInput.kt +52 -0
  13. package/compose/src/commonMain/kotlin/vn/momo/kits/application/Context.kt +124 -0
  14. package/compose/src/commonMain/kotlin/vn/momo/kits/application/FloatingButton.kt +183 -0
  15. package/compose/src/commonMain/kotlin/vn/momo/kits/application/Header.kt +207 -0
  16. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderAnimated.kt +39 -0
  17. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderBackground.kt +86 -0
  18. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderDefault.kt +76 -0
  19. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderExtended.kt +76 -0
  20. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderRight.kt +293 -0
  21. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderTitle.kt +33 -0
  22. package/compose/src/commonMain/kotlin/vn/momo/kits/application/LiteScreen.kt +833 -0
  23. package/compose/src/commonMain/kotlin/vn/momo/kits/application/Localize.kt +269 -0
  24. package/compose/src/commonMain/kotlin/vn/momo/kits/application/NavigationContainer.kt +110 -0
  25. package/compose/src/commonMain/kotlin/vn/momo/kits/application/Screen.kt +378 -0
  26. package/compose/src/commonMain/kotlin/vn/momo/kits/application/WidgetContainer.kt +56 -0
  27. package/compose/src/commonMain/kotlin/vn/momo/kits/application/useHeaderSearchAnimation.kt +69 -0
  28. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Avatar.kt +157 -0
  29. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Badge.kt +82 -0
  30. package/compose/src/commonMain/kotlin/vn/momo/kits/components/BadgeDot.kt +32 -0
  31. package/compose/src/commonMain/kotlin/vn/momo/kits/components/BadgeRibbon.kt +338 -0
  32. package/compose/src/commonMain/kotlin/vn/momo/kits/components/BaselineView.kt +175 -0
  33. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Button.kt +356 -0
  34. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Carousel.kt +113 -0
  35. package/compose/src/commonMain/kotlin/vn/momo/kits/components/CheckBox.kt +89 -0
  36. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Chip.kt +128 -0
  37. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Collapse.kt +209 -0
  38. package/compose/src/commonMain/kotlin/vn/momo/kits/components/CupertinoOverscroll.kt +542 -0
  39. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Divider.kt +49 -0
  40. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Icon.kt +76 -0
  41. package/compose/src/commonMain/kotlin/vn/momo/kits/components/IconButton.kt +125 -0
  42. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Image.kt +231 -0
  43. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Information.kt +189 -0
  44. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Input.kt +475 -0
  45. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputDropDown.kt +114 -0
  46. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputMoney.kt +266 -0
  47. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputOTP.kt +240 -0
  48. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputPhoneNumber.kt +223 -0
  49. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputSearch.kt +264 -0
  50. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputTextArea.kt +185 -0
  51. package/compose/src/commonMain/kotlin/vn/momo/kits/components/LazyColumnWithBouncing.kt +343 -0
  52. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Loader.kt +108 -0
  53. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PaginationDot.kt +56 -0
  54. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PaginationNumber.kt +41 -0
  55. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PaginationScroll.kt +84 -0
  56. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PaginationWhiteDot.kt +40 -0
  57. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PopupNotify.kt +361 -0
  58. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PopupPromotion.kt +108 -0
  59. package/compose/src/commonMain/kotlin/vn/momo/kits/components/ProgressInfo.kt +310 -0
  60. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Radio.kt +59 -0
  61. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Rating.kt +87 -0
  62. package/compose/src/commonMain/kotlin/vn/momo/kits/components/ScaleSizeScope.kt +21 -0
  63. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Skeleton.kt +90 -0
  64. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Slider.kt +323 -0
  65. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Stepper.kt +220 -0
  66. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Steps.kt +473 -0
  67. package/compose/src/commonMain/kotlin/vn/momo/kits/components/SuggestAction.kt +122 -0
  68. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Swipe.kt +199 -0
  69. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Switch.kt +91 -0
  70. package/compose/src/commonMain/kotlin/vn/momo/kits/components/TabView.kt +552 -0
  71. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Tag.kt +88 -0
  72. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Text.kt +125 -0
  73. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Title.kt +207 -0
  74. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Tooltip.kt +498 -0
  75. package/compose/src/commonMain/kotlin/vn/momo/kits/components/TrustBanner.kt +176 -0
  76. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Uploader.kt +182 -0
  77. package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/DateTimePicker.kt +193 -0
  78. package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/DateTimePickerTypes.kt +29 -0
  79. package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/DateTimePickerUtils.kt +234 -0
  80. package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/WheelPicker.kt +188 -0
  81. package/compose/src/commonMain/kotlin/vn/momo/kits/const/Colors.kt +325 -0
  82. package/compose/src/commonMain/kotlin/vn/momo/kits/const/HostFont.kt +16 -0
  83. package/compose/src/commonMain/kotlin/vn/momo/kits/const/KitDesignSystem.kt +18 -0
  84. package/compose/src/commonMain/kotlin/vn/momo/kits/const/KitFontScale.kt +22 -0
  85. package/compose/src/commonMain/kotlin/vn/momo/kits/const/Radius.kt +12 -0
  86. package/compose/src/commonMain/kotlin/vn/momo/kits/const/Spacing.kt +16 -0
  87. package/compose/src/commonMain/kotlin/vn/momo/kits/const/Theme.kt +188 -0
  88. package/compose/src/commonMain/kotlin/vn/momo/kits/const/Typography.kt +297 -0
  89. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/Card.kt +54 -0
  90. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/Item.kt +50 -0
  91. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/ScreenLayout.kt +232 -0
  92. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/Section.kt +41 -0
  93. package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/AutomationId.kt +52 -0
  94. package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/Clickable.kt +66 -0
  95. package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/Conditional.kt +11 -0
  96. package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/DeprecatedModifier.kt +104 -0
  97. package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/Shadow.kt +48 -0
  98. package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/Size.kt +51 -0
  99. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/BottomSheet.kt +338 -0
  100. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/HostBackGestureBridge.kt +58 -0
  101. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/ModalScreen.kt +137 -0
  102. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigation.kt +106 -0
  103. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/NavigationContainer.kt +205 -0
  104. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigator.kt +393 -0
  105. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/ScrollToTop.kt +8 -0
  106. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/StackScreen.kt +708 -0
  107. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/StackTransition.kt +148 -0
  108. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/SwipeBackHaptic.kt +35 -0
  109. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTab.kt +203 -0
  110. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTabBar.kt +232 -0
  111. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/CurvedContainer.kt +86 -0
  112. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/DuoBarCompression.kt +98 -0
  113. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/DuoTabBar.kt +312 -0
  114. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/FloatingButton.kt +169 -0
  115. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/Header.kt +408 -0
  116. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderBackground.kt +92 -0
  117. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderJourney.kt +105 -0
  118. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderLocation.kt +74 -0
  119. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderRight.kt +366 -0
  120. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderTitle.kt +37 -0
  121. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderUser.kt +363 -0
  122. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/SideBar.kt +185 -0
  123. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/SnackBar.kt +132 -0
  124. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/tracking/ScreenTracker.kt +127 -0
  125. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/BackGesture.kt +12 -0
  126. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/ComposeLottieAnimation.kt +91 -0
  127. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/OsFontScale.kt +9 -0
  128. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/Platform.kt +74 -0
  129. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/ReservedRegions.kt +13 -0
  130. package/compose/src/commonMain/kotlin/vn/momo/kits/utils/Icons.kt +1329 -0
  131. package/compose/src/commonMain/kotlin/vn/momo/kits/utils/Resources.kt +55 -0
  132. package/compose/src/commonMain/kotlin/vn/momo/kits/utils/Tracking.kt +15 -0
  133. package/compose/src/commonMain/kotlin/vn/momo/kits/utils/Utils.kt +118 -0
  134. package/compose/src/iosMain/kotlin/vn/momo/kits/const/HostFont.ios.kt +40 -0
  135. package/compose/src/iosMain/kotlin/vn/momo/kits/navigation/HostBackGesture.ios.kt +45 -0
  136. package/compose/src/iosMain/kotlin/vn/momo/kits/navigation/ScrollToTop.ios.kt +32 -0
  137. package/compose/src/iosMain/kotlin/vn/momo/kits/platform/BackGesture.ios.kt +38 -0
  138. package/compose/src/iosMain/kotlin/vn/momo/kits/platform/OsFontScale.ios.kt +66 -0
  139. package/compose/src/iosMain/kotlin/vn/momo/kits/platform/Platform.ios.kt +217 -0
  140. package/compose/src/iosMain/kotlin/vn/momo/kits/platform/ReservedRegions.ios.kt +86 -0
  141. package/gradle/libs.versions.toml +69 -0
  142. package/gradle/wrapper/gradle-wrapper.jar +0 -0
  143. package/gradle/wrapper/gradle-wrapper.properties +8 -0
  144. package/gradle.properties +26 -0
  145. package/gradlew +252 -0
  146. package/gradlew.bat +94 -0
  147. package/ios/Application/ApplicationEnvironment.swift +6 -0
  148. package/ios/Application/Components.swift +1 -3
  149. package/ios/Application/HeaderRight.swift +32 -14
  150. package/ios/Application/Layout/ScreenLayout.swift +312 -0
  151. package/ios/Application/Navigation/BottomTab/BottomTab.swift +67 -10
  152. package/ios/Application/Navigation/BottomTab/BottomTabBar.swift +5 -2
  153. package/ios/Application/Navigation/BottomTab/DuoBarCompression.swift +105 -0
  154. package/ios/Application/Navigation/BottomTab/DuoTabBar.swift +304 -0
  155. package/ios/Application/Navigation/HeaderUser.swift +4 -4
  156. package/ios/Application/Navigation/NavigationContainer.swift +48 -42
  157. package/ios/Application/Navigation/Navigator.swift +122 -29
  158. package/ios/Application/Navigation/NavigatorManager.swift +13 -6
  159. package/ios/Application/Navigation/Overplay/BottomSheet.swift +41 -4
  160. package/ios/Application/Navigation/Overplay/ModalScreen.swift +7 -0
  161. package/ios/Application/Navigation/component/DuoScrollGradient.swift +54 -0
  162. package/ios/Application/Navigation/component/Header.swift +11 -6
  163. package/ios/Application/Navigation/component/HeaderBackground.swift +3 -2
  164. package/ios/Application/Navigation/component/NavigationFooter.swift +1 -1
  165. package/ios/Application/Navigation/component/NavigationHeaderRight.swift +93 -9
  166. package/ios/Application/Navigation/component/SideBar.swift +155 -0
  167. package/ios/Application/StackScreen.swift +175 -18
  168. package/ios/CalculatorKeyboard/CalculatorKeyboard.swift +59 -5
  169. package/ios/Colors+Radius+Spacing/Colors.swift +1 -1
  170. package/ios/Extensions/Color++.swift +20 -9
  171. package/ios/Extensions/OptionalEnvironmentObject.swift +12 -0
  172. package/ios/Icon/Icon.swift +14 -22
  173. package/ios/Icon/Icons.swift +1329 -0
  174. package/ios/Image/Image.swift +16 -5
  175. package/ios/Lottie/LottieView.swift +4 -2
  176. package/ios/Popup/PopupDisplay.swift +84 -67
  177. package/ios/Popup/PopupLayout.swift +45 -0
  178. package/ios/Popup/PopupNotify.swift +44 -26
  179. package/ios/Popup/PopupPromotion.swift +18 -6
  180. package/ios/Resources/dot_loading.json +558 -0
  181. package/ios/Resources/lottie_circle_loader.json +1 -0
  182. package/ios/Template/TrustBanner/TrustBanner.swift +69 -31
  183. package/ios/Typography/FontScale.swift +3 -3
  184. package/ios/native-kits.podspec +1 -1
  185. package/ios-demo/MoMoUIKitsDemo.podspec +1 -1
  186. package/ios-demo/README.md +120 -0
  187. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/DuoTabDemo.swift +80 -0
  188. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/Home.swift +2 -0
  189. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/IPhoneDuoDemo.swift +173 -0
  190. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PopupDisplayDemo.swift +1 -0
  191. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PopupNotifyDemo.swift +2 -2
  192. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PopupPromotionDemo.swift +2 -0
  193. package/local.properties +8 -0
  194. package/package.json +1 -1
  195. package/scripts/gen-ios-icons.mjs +36 -0
  196. package/settings.gradle.kts +64 -0
@@ -0,0 +1,312 @@
1
+ //
2
+ // ScreenLayout.swift
3
+ // MoMoUIKits
4
+ //
5
+ // Port of momo-kits RN `useScreenLayout` (Layout/utils.ts) and `Application/device.ts`:
6
+ // window size, safe-area insets, content width, header geometry and the iPhone Duo side bar.
7
+ //
8
+
9
+ import SwiftUI
10
+ import UIKit
11
+
12
+ public enum SideBarEdge: Equatable {
13
+ case left, right
14
+ }
15
+
16
+ public struct SideBarLayout: Equatable {
17
+ public let edge: SideBarEdge
18
+ public let width: CGFloat
19
+ /// Preferred window y of the first control; StackScreen still keeps the column above the bottom bars.
20
+ public let top: CGFloat
21
+ /// Room kept free above the window bottom, clear of a camera at the bottom end of the strip.
22
+ public let bottom: CGFloat
23
+ }
24
+
25
+ public struct ScreenLayout: Equatable {
26
+ public let width: CGFloat
27
+ public let height: CGFloat
28
+ public let isIPhoneDuo: Bool
29
+ public let insets: EdgeInsets
30
+ public let insetLeft: CGFloat
31
+ public let insetRight: CGFloat
32
+ public let contentWidth: CGFloat
33
+ public let headerHeight: CGFloat
34
+ /// 0 when the status bar is off the top edge, so the bar row fills the header and keeps the title centered.
35
+ public let headerStatusBarHeight: CGFloat
36
+ /// iPhone Duo moves back and toolkit into a column in the side safe area; nil while they stay in the bar.
37
+ public let sideBar: SideBarLayout?
38
+
39
+ public var headerBarHeight: CGFloat { headerHeight - headerStatusBarHeight }
40
+ }
41
+
42
+ // Measured by momo-kits RN (Layout/utils.ts) on the iOS 27.1 iPhone Duo simulator; keep both in sync.
43
+ enum DuoMetrics {
44
+ // Inner display is ~1.42, outer ~1.45; every slab iPhone is 1.77 or taller.
45
+ static let maxAspectRatio: CGFloat = 1.6
46
+ // Outer display is 466pt tall in landscape, inner 669pt.
47
+ static let innerMinHeight: CGFloat = 600
48
+ static let controlSize: CGFloat = 48
49
+ static let itemPitch: CGFloat = 50
50
+ static let firstItemTop: CGFloat = 170
51
+ static let firstItemTopLandscape: CGFloat = 120
52
+ // Outer display in portrait: the header is 4pt taller than the iPhone floor and its title
53
+ // lines up with the camera punchout (y 24–66 at the top of the strip).
54
+ static let outerHeaderHeight: CGFloat = 76
55
+ static let outerCameraCenterY: CGFloat = 45
56
+ // Outer display in landscape has no status bar in the strip: controls keep this far from
57
+ // both ends unless a reserved region (the camera at either end) asks for more.
58
+ static let edgeInset: CGFloat = 24
59
+ // Widest short side of any single-display iPhone.
60
+ static let iPhoneMaxShortSide: CGFloat = 440
61
+ }
62
+
63
+ enum HeaderMetrics {
64
+ static let barHeight: CGFloat = HEADER_HEIGHT
65
+ static let iPhoneMinHeight: CGFloat = 72
66
+ // A smaller top inset means the status bar is not on the top edge.
67
+ static let statusBarMinInset: CGFloat = 20
68
+ }
69
+
70
+ private var isPhoneIdiom: Bool { UIDevice.current.userInterfaceIdiom == .phone }
71
+
72
+ private var keyWindow: UIWindow? {
73
+ UIApplication.shared.connectedScenes
74
+ .compactMap { ($0 as? UIWindowScene)?.keyWindow }
75
+ .first
76
+ }
77
+
78
+ extension ScreenLayout {
79
+ /// Snapshot of the key window; not reactive, so views read `\.screenLayout` instead.
80
+ public static var current: ScreenLayout {
81
+ guard let window = keyWindow else {
82
+ return make(size: UIScreen.main.bounds.size, insets: EdgeInsets())
83
+ }
84
+ let inset = window.safeAreaInsets
85
+ return make(
86
+ size: window.bounds.size,
87
+ insets: EdgeInsets(top: inset.top, leading: inset.left, bottom: inset.bottom, trailing: inset.right)
88
+ )
89
+ }
90
+
91
+ static func make(
92
+ size: CGSize,
93
+ insets: EdgeInsets,
94
+ reservedRegions: [CGRect] = ReservedRegions.current,
95
+ isPhone: Bool = isPhoneIdiom
96
+ ) -> ScreenLayout {
97
+ let width = size.width
98
+ let height = size.height
99
+ var (headerHeight, headerStatusBarHeight) = headerGeometry(topInset: insets.top, isPhone: isPhone)
100
+ let isDuo = isPhone && isIPhoneDuoWindow(width: width, height: height, insets: insets)
101
+ guard isDuo else {
102
+ return ScreenLayout(
103
+ width: width, height: height, isIPhoneDuo: false, insets: insets,
104
+ insetLeft: 0, insetRight: 0, contentWidth: width,
105
+ headerHeight: headerHeight, headerStatusBarHeight: headerStatusBarHeight, sideBar: nil
106
+ )
107
+ }
108
+
109
+ var sideBar: SideBarLayout?
110
+ let sideInset = max(insets.leading, insets.trailing)
111
+ // narrower strips cannot host a control 12pt from the inner edge
112
+ if sideInset >= DuoMetrics.controlSize + Spacing.M {
113
+ var top = insets.top + HeaderMetrics.barHeight + Spacing.M
114
+ if insets.top < HeaderMetrics.statusBarMinInset {
115
+ // status bar lives in the strip: start under its cluster, as the native bar does
116
+ if width <= height {
117
+ top = DuoMetrics.firstItemTop
118
+ } else if height >= DuoMetrics.innerMinHeight {
119
+ top = DuoMetrics.firstItemTopLandscape
120
+ } else {
121
+ // outer display in landscape: no status bar in the strip
122
+ top = DuoMetrics.edgeInset
123
+ }
124
+
125
+ if height > width && width < DuoMetrics.innerMinHeight {
126
+ // outer display: the row starts below the top so the title centers on the camera
127
+ headerHeight = DuoMetrics.outerHeaderHeight
128
+ headerStatusBarHeight = DuoMetrics.outerCameraCenterY * 2 - DuoMetrics.outerHeaderHeight
129
+ }
130
+ }
131
+
132
+ let edge: SideBarEdge = insets.trailing >= insets.leading ? .right : .left
133
+ let stripLeft = edge == .right ? width - sideInset : 0
134
+ // clear of the camera and of the status bar cluster, which grows for calls and Live
135
+ // Activities; region frames already include the system's margin
136
+ let occlusion = stripOcclusion(reservedRegions, stripLeft: stripLeft, stripRight: stripLeft + sideInset, windowHeight: height)
137
+ sideBar = SideBarLayout(
138
+ edge: edge,
139
+ width: sideInset,
140
+ top: max(top, occlusion.top),
141
+ bottom: max(DuoMetrics.edgeInset, occlusion.bottom)
142
+ )
143
+ }
144
+
145
+ return ScreenLayout(
146
+ width: width, height: height, isIPhoneDuo: true, insets: insets,
147
+ insetLeft: insets.leading, insetRight: insets.trailing,
148
+ contentWidth: width - insets.leading - insets.trailing,
149
+ headerHeight: headerHeight, headerStatusBarHeight: headerStatusBarHeight, sideBar: sideBar
150
+ )
151
+ }
152
+
153
+ private static func headerGeometry(topInset: CGFloat, isPhone: Bool) -> (CGFloat, CGFloat) {
154
+ let height = topInset + HeaderMetrics.barHeight
155
+ guard isPhone else { return (height, topInset) }
156
+ return (
157
+ max(height, HeaderMetrics.iPhoneMinHeight),
158
+ topInset < HeaderMetrics.statusBarMinInset ? 0 : topInset
159
+ )
160
+ }
161
+
162
+ /// How far system regions reach into a side strip from its top and from its bottom end
163
+ /// (e.g. the camera, which sits at either end depending on the rotation).
164
+ private static func stripOcclusion(
165
+ _ regions: [CGRect],
166
+ stripLeft: CGFloat,
167
+ stripRight: CGFloat,
168
+ windowHeight: CGFloat
169
+ ) -> (top: CGFloat, bottom: CGFloat) {
170
+ regions.reduce(into: (top: CGFloat(0), bottom: CGFloat(0))) { occlusion, region in
171
+ guard region.minX < stripRight && region.maxX > stripLeft else { return }
172
+ if region.midY < windowHeight / 2 {
173
+ occlusion.top = max(occlusion.top, region.maxY)
174
+ } else {
175
+ occlusion.bottom = max(occlusion.bottom, windowHeight - region.minY)
176
+ }
177
+ }
178
+ }
179
+
180
+ // Slab iPhones report equal side insets in landscape; iPhone Duo moves the status bar to one side.
181
+ private static func isIPhoneDuoWindow(width: CGFloat, height: CGFloat, insets: EdgeInsets) -> Bool {
182
+ let shortSide = min(width, height)
183
+ let isSquare = shortSide > 0 && max(width, height) / shortSide < DuoMetrics.maxAspectRatio
184
+ return isSquare || insets.leading != insets.trailing
185
+ }
186
+
187
+ /// Below the side status bar: in portrait it is about twice as tall as its inset is wide.
188
+ public var duoTopInset: CGFloat {
189
+ max(insets.top, height > width ? max(insets.leading, insets.trailing) * 2 : 0)
190
+ }
191
+ }
192
+
193
+ /// Port of RN `device.ts` `isIPhoneDuo()`: UIScreen stays on the outer display, so this
194
+ /// still matches when Split View narrows the window. Used by popups and overlays.
195
+ public func isIPhoneDuoDevice(windowSize: CGSize? = nil) -> Bool {
196
+ guard isPhoneIdiom else { return false }
197
+ let screen = UIScreen.main.bounds.size
198
+ let window = windowSize ?? keyWindow?.bounds.size ?? screen
199
+ return max(min(screen.width, screen.height), min(window.width, window.height)) > DuoMetrics.iPhoneMaxShortSide
200
+ }
201
+
202
+ // MARK: - Reserved regions
203
+
204
+ /// System occlusion regions (iOS 27.1+) in window coordinates: the iPhone Duo camera, and the
205
+ /// status bar cluster as it grows for a call or a Live Activity. Empty before iOS 27.1.
206
+ enum ReservedRegions {
207
+ // last report of the probe, so `ScreenLayout.current` includes it
208
+ static var current: [CGRect] = []
209
+ }
210
+
211
+ /// Invisible probe: reading the regions in layoutSubviews makes UIKit run it again when they
212
+ /// change, so nothing polls. Port of RN `@momo-kits/reserved-regions`.
213
+ private final class ReservedRegionsProbe: UIView {
214
+ var onChange: (() -> Void)?
215
+ private var last: [CGRect]?
216
+
217
+ override init(frame: CGRect) {
218
+ super.init(frame: frame)
219
+ isUserInteractionEnabled = false
220
+ }
221
+
222
+ required init?(coder: NSCoder) { nil }
223
+
224
+ override func layoutSubviews() {
225
+ super.layoutSubviews()
226
+ let regions = occlusionRegions()
227
+ guard regions != last else { return }
228
+ last = regions
229
+ ReservedRegions.current = regions
230
+ // layout pass: publish on the next turn instead of mutating SwiftUI state mid-update
231
+ DispatchQueue.main.async { [weak self] in self?.onChange?() }
232
+ }
233
+
234
+ override func didMoveToWindow() {
235
+ super.didMoveToWindow()
236
+ setNeedsLayout()
237
+ }
238
+
239
+ // The API is newer than the SDKs hosts may build with, so it is reached through the runtime.
240
+ private func occlusionRegions() -> [CGRect] {
241
+ let regionsOfKind = NSSelectorFromString("reservedRegionsOfKind:")
242
+ guard window != nil, responds(to: regionsOfKind),
243
+ let kindType = NSClassFromString("UIViewReservedRegionKind") as? NSObject.Type,
244
+ let kind = (kindType as AnyObject).perform(NSSelectorFromString("occlusionRegionKind"))?.takeUnretainedValue(),
245
+ let regions = perform(regionsOfKind, with: kind)?.takeUnretainedValue() as? [NSObject]
246
+ else { return [] }
247
+ return regions.compactMap { ($0.value(forKey: "frame") as? NSValue)?.cgRectValue }
248
+ .map { convert($0, to: nil) }
249
+ }
250
+ }
251
+
252
+ private struct ReservedRegionsProbeView: UIViewRepresentable {
253
+ let onChange: () -> Void
254
+
255
+ func makeUIView(context: Context) -> ReservedRegionsProbe {
256
+ let probe = ReservedRegionsProbe()
257
+ probe.onChange = onChange
258
+ return probe
259
+ }
260
+
261
+ func updateUIView(_ probe: ReservedRegionsProbe, context: Context) {
262
+ probe.onChange = onChange
263
+ }
264
+ }
265
+
266
+ // MARK: - Environment
267
+
268
+ private struct ScreenLayoutKey: EnvironmentKey {
269
+ // Computed so an un-injected subtree (bare UIHostingController) still reads the live window.
270
+ static var defaultValue: ScreenLayout { .current }
271
+ }
272
+
273
+ public extension EnvironmentValues {
274
+ var screenLayout: ScreenLayout {
275
+ get { self[ScreenLayoutKey.self] }
276
+ set { self[ScreenLayoutKey.self] = newValue }
277
+ }
278
+ }
279
+
280
+ /// Re-reads the window whenever this view's size changes (rotation, fold/unfold, Split View).
281
+ private struct ScreenLayoutReader: ViewModifier {
282
+ @State private var layout: ScreenLayout = .current
283
+
284
+ func body(content: Content) -> some View {
285
+ content
286
+ .environment(\.screenLayout, layout)
287
+ .background(
288
+ GeometryReader { proxy in
289
+ Color.clear
290
+ .onAppear { refresh() }
291
+ .onChange(of: proxy.size) { _ in refresh() }
292
+ .onChange(of: proxy.safeAreaInsets) { _ in refresh() }
293
+ }
294
+ )
295
+ .background(ReservedRegionsProbeView { refresh() }.ignoresSafeArea())
296
+ .onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in
297
+ refresh()
298
+ }
299
+ }
300
+
301
+ private func refresh() {
302
+ let next = ScreenLayout.current
303
+ if next != layout { layout = next }
304
+ }
305
+ }
306
+
307
+ public extension View {
308
+ /// Provides `\.screenLayout`; NavigationContainer applies it, a bare UIHostingController root should too.
309
+ func momoScreenLayout() -> some View {
310
+ modifier(ScreenLayoutReader())
311
+ }
312
+ }
@@ -14,19 +14,61 @@ import SwiftUI
14
14
  public struct BottomTab: View {
15
15
  private let items: [BottomTabItem]
16
16
  private let floatingButton: BottomTabFloatingButton?
17
+ private let compressionBehavior: DuoBarCompressionBehavior
17
18
 
18
19
  @EnvironmentObject private var navigator: Navigator
20
+ @Environment(\.screenLayout) private var layout
19
21
  @State private var selectedIndex: Int = 0
22
+ @State private var toolbarHeight = DuoBarCompression.defaultToolbarHeight
23
+ @State private var expandedBar: DuoBar?
24
+ @State private var openedBar: DuoBar?
20
25
 
21
- public init(items: [BottomTabItem], floatingButton: BottomTabFloatingButton? = nil) {
26
+ /// `compressionBehavior`: on iPhone Duo, when the side bar can't hold both bars, which one
27
+ /// starts compressed; tapping the compressed one swaps them.
28
+ public init(
29
+ items: [BottomTabItem],
30
+ floatingButton: BottomTabFloatingButton? = nil,
31
+ compressionBehavior: DuoBarCompressionBehavior = .toolbar
32
+ ) {
22
33
  self.items = items
23
34
  self.floatingButton = floatingButton
35
+ self.compressionBehavior = compressionBehavior
24
36
  }
25
37
 
26
38
  private var bottomInset: CGFloat { safeAreaBottomInset() }
27
- private var reservedBottom: CGFloat { BOTTOM_TAB_BAR_HEIGHT + bottomInset + Spacing.S }
39
+ // the Duo bars overlay the content or sit in the side strip, so only the home indicator is kept clear
40
+ private var reservedBottom: CGFloat {
41
+ layout.isIPhoneDuo ? layout.insets.bottom : BOTTOM_TAB_BAR_HEIGHT + bottomInset + Spacing.S
42
+ }
43
+
44
+ private var compression: DuoBarCompression {
45
+ DuoBarCompression.resolve(
46
+ layout: layout,
47
+ tabCount: items.count,
48
+ hasQr: floatingButton != nil,
49
+ toolbarHeight: toolbarHeight,
50
+ // 'toolbar' compression keeps the tab bar whole until the toolbar is tapped
51
+ expanded: Binding(
52
+ get: { expandedBar ?? (compressionBehavior == .toolbar ? .tabBar : .toolbar) },
53
+ set: { expandedBar = $0 }
54
+ ),
55
+ opened: $openedBar,
56
+ setToolbarHeight: { height in
57
+ if toolbarHeight != height { toolbarHeight = height }
58
+ }
59
+ )
60
+ }
61
+
62
+ private func duoInsets(_ compression: DuoBarCompression) -> DuoTabBarInsets {
63
+ layout.duoTabBarInsets(
64
+ itemCount: items.count,
65
+ hasFloatingButton: floatingButton != nil,
66
+ tabBarCompressed: compression.tabBarCompressed
67
+ )
68
+ }
28
69
 
29
70
  public var body: some View {
71
+ let compression = compression
30
72
  ZStack(alignment: .bottom) {
31
73
  // Tab content area, inset above the bar.
32
74
  ZStack {
@@ -38,20 +80,31 @@ public struct BottomTab: View {
38
80
  }
39
81
  }
40
82
  .padding(.bottom, reservedBottom)
83
+ .environment(\.duoTabBarInsets, duoInsets(compression))
84
+ .environment(\.duoBarCompression, compression)
41
85
  .animation(.easeInOut(duration: 0.2), value: selectedIndex)
42
86
 
43
- // Tab bar + home-indicator filler.
44
- VStack(spacing: 0) {
45
- BottomTabBar(
87
+ if layout.isIPhoneDuo {
88
+ DuoTabBar(
46
89
  items: items,
47
90
  floatingButton: floatingButton,
48
91
  selectedIndex: selectedIndex,
49
- onTabSelected: { index in
50
- if index != selectedIndex { selectedIndex = index }
51
- }
92
+ onTabSelected: selectTab,
93
+ compression: compression
52
94
  )
53
- Colors.black01
54
- .frame(height: bottomInset + Spacing.S)
95
+ } else {
96
+ // Tab bar + home-indicator filler.
97
+ VStack(spacing: 0) {
98
+ BottomTabBar(
99
+ items: items,
100
+ floatingButton: floatingButton,
101
+ selectedIndex: selectedIndex,
102
+ onTabSelected: selectTab
103
+ )
104
+ Colors.black01
105
+ .frame(height: bottomInset + Spacing.S)
106
+ .ignoresSafeArea(edges: .horizontal)
107
+ }
55
108
  }
56
109
  }
57
110
  .frame(maxWidth: .infinity, maxHeight: .infinity)
@@ -59,6 +112,10 @@ public struct BottomTab: View {
59
112
  .preference(key: IsBottomTabRootKey.self, value: true)
60
113
  }
61
114
 
115
+ private func selectTab(_ index: Int) {
116
+ if index != selectedIndex { selectedIndex = index }
117
+ }
118
+
62
119
  @ViewBuilder
63
120
  private func tabScreen(for index: Int) -> some View {
64
121
  let item = items[index]
@@ -65,7 +65,10 @@ 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
+
70
+ // the bar sits between the side safe areas
71
+ private var screenWidth: CGFloat { layout.width - layout.insets.leading - layout.insets.trailing }
69
72
  private var mid: Int { items.count / 2 }
70
73
 
71
74
  private var tabWidth: CGFloat {
@@ -95,7 +98,7 @@ struct BottomTabBar: View {
95
98
  }
96
99
  .frame(maxWidth: .infinity)
97
100
  .frame(height: BOTTOM_TAB_BAR_HEIGHT)
98
- .background(Colors.black01)
101
+ .background(Colors.black01.ignoresSafeArea(edges: .horizontal))
99
102
 
100
103
  // Animated selection indicator.
101
104
  RoundedRectangle(cornerRadius: 2)
@@ -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
+ }