@momo-kits/native-kits 0.167.1-sizescale.2-debug → 0.168.1-beta.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 (223) hide show
  1. package/CLAUDE.md +45 -3
  2. package/README.md +2 -2
  3. package/compose/build.gradle.kts +9 -1
  4. package/compose/build.gradle.kts.backup +8 -0
  5. package/compose/compose.podspec +1 -1
  6. package/compose/src/androidMain/kotlin/vn/momo/kits/platform/Platform.android.kt +3 -4
  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 +353 -113
  9. package/compose/src/commonMain/kotlin/vn/momo/kits/application/WidgetContainer.kt +2 -2
  10. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Badge.kt +20 -21
  11. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Button.kt +2 -1
  12. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Carousel.kt +4 -2
  13. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Chip.kt +1 -1
  14. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Icon.kt +24 -14
  15. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Image.kt +1 -1
  16. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Information.kt +1 -1
  17. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Input.kt +9 -6
  18. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputMoney.kt +11 -12
  19. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputOTP.kt +4 -9
  20. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputPhoneNumber.kt +2 -2
  21. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputSearch.kt +10 -9
  22. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputTextArea.kt +6 -6
  23. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PopupNotify.kt +55 -22
  24. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PopupPromotion.kt +12 -1
  25. package/compose/src/commonMain/kotlin/vn/momo/kits/components/ProgressInfo.kt +7 -9
  26. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Radio.kt +1 -1
  27. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Slider.kt +16 -9
  28. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Swipe.kt +13 -5
  29. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Switch.kt +3 -2
  30. package/compose/src/commonMain/kotlin/vn/momo/kits/components/TabView.kt +14 -6
  31. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Tag.kt +19 -21
  32. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Text.kt +2 -2
  33. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Tooltip.kt +6 -3
  34. package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/DateTimePickerUtils.kt +3 -1
  35. package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/WheelPicker.kt +7 -15
  36. package/compose/src/commonMain/kotlin/vn/momo/kits/const/Spacing.kt +11 -15
  37. package/compose/src/commonMain/kotlin/vn/momo/kits/const/Theme.kt +10 -4
  38. package/compose/src/commonMain/kotlin/vn/momo/kits/const/Typography.kt +7 -23
  39. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/Card.kt +13 -11
  40. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/Item.kt +21 -9
  41. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/ScreenLayout.kt +309 -0
  42. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/Section.kt +11 -9
  43. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/BottomSheet.kt +43 -4
  44. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/ModalScreen.kt +8 -0
  45. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigation.kt +10 -0
  46. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/NavigationContainer.kt +8 -7
  47. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigator.kt +6 -0
  48. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/StackScreen.kt +197 -60
  49. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTab.kt +134 -81
  50. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTabBar.kt +54 -62
  51. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/DuoBarCompression.kt +98 -0
  52. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/DuoTabBar.kt +312 -0
  53. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/Header.kt +126 -29
  54. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderBackground.kt +34 -14
  55. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderJourney.kt +3 -3
  56. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderLocation.kt +1 -2
  57. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderRight.kt +116 -26
  58. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderTitle.kt +4 -2
  59. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderUser.kt +2 -3
  60. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/SideBar.kt +192 -0
  61. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/SnackBar.kt +2 -1
  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 +13 -0
  64. package/compose/src/commonMain/kotlin/vn/momo/kits/utils/Utils.kt +13 -0
  65. package/compose/src/iosMain/kotlin/vn/momo/kits/platform/OsFontScale.ios.kt +1 -3
  66. package/compose/src/iosMain/kotlin/vn/momo/kits/platform/Platform.ios.kt +10 -11
  67. package/compose/src/iosMain/kotlin/vn/momo/kits/platform/ReservedRegions.ios.kt +94 -0
  68. package/gradle/libs.versions.toml +1 -1
  69. package/gradle.properties +1 -1
  70. package/ios/Application/ApplicationEnvironment.swift +17 -0
  71. package/ios/Application/MaxApi.swift +30 -3
  72. package/ios/Avatar/Avatar.swift +4 -3
  73. package/ios/Badge/Badge.swift +6 -4
  74. package/ios/Badge/BadgeDot.swift +1 -1
  75. package/ios/Badge/BadgeRibbon.swift +0 -1
  76. package/ios/Button/Button.swift +15 -12
  77. package/ios/CalculatorKeyboard/CalculatorKeyboard.swift +64 -18
  78. package/ios/Carousel/Carousel.swift +29 -12
  79. package/ios/Checkbox/Checkbox.swift +7 -3
  80. package/ios/Chip/Chip.swift +5 -4
  81. package/ios/Collapse/Collapse.swift +70 -19
  82. package/ios/Colors+Radius+Spacing/Colors.swift +10 -1
  83. package/ios/Colors+Radius+Spacing/Radius.swift +6 -0
  84. package/ios/DateTimePicker/DateTimePicker.swift +54 -20
  85. package/ios/DateTimePicker/WheelPicker.swift +103 -105
  86. package/ios/Divider/Divider.swift +37 -4
  87. package/ios/Extensions/Color++.swift +38 -9
  88. package/ios/Extensions/OptionalEnvironmentObject.swift +12 -0
  89. package/ios/Extensions/SkeletonUICompat.swift +11 -0
  90. package/ios/Icon/Icon.swift +21 -23
  91. package/ios/Icon/Icons.swift +1329 -0
  92. package/ios/IconButton/IconButton.swift +15 -7
  93. package/ios/Image/Image.swift +7 -23
  94. package/ios/Information/Information.swift +27 -9
  95. package/ios/Input/ErrorView.swift +2 -2
  96. package/ios/Input/Input.swift +160 -147
  97. package/ios/Input/InputPhoneNumber.swift +12 -9
  98. package/ios/Input/InputSearch.swift +107 -77
  99. package/ios/Input/InputTextArea.swift +128 -145
  100. package/ios/InputDropDown/InputDropDown.swift +10 -7
  101. package/ios/InputMoney/InputMoney.swift +33 -11
  102. package/ios/InputOTP/InputOTP.swift +22 -12
  103. package/ios/Layout/Card.swift +50 -0
  104. package/ios/Layout/GridContext.swift +187 -3
  105. package/ios/Layout/Item.swift +5 -4
  106. package/ios/Layout/ScreenLayout.swift +380 -0
  107. package/ios/Layout/Section.swift +4 -100
  108. package/ios/Loader/Loader.swift +5 -9
  109. package/ios/Lottie/LottieView.swift +14 -2
  110. package/ios/{Application/Navigation/Overplay → Navigation}/BottomSheet.swift +169 -29
  111. package/ios/Navigation/BottomTab/BottomTab.swift +332 -0
  112. package/ios/{Application/Navigation → Navigation}/BottomTab/BottomTabBar.swift +18 -7
  113. package/ios/Navigation/BottomTab/DuoBarCompression.swift +105 -0
  114. package/ios/Navigation/BottomTab/DuoTabBar.swift +304 -0
  115. package/ios/{Application → Navigation/Component}/FloatingButton.swift +43 -54
  116. package/ios/Navigation/Component/Header.swift +545 -0
  117. package/ios/Navigation/Component/HeaderBackground.swift +133 -0
  118. package/ios/Navigation/Component/HeaderJourney.swift +73 -0
  119. package/ios/Navigation/Component/HeaderLocation.swift +48 -0
  120. package/ios/Navigation/Component/HeaderRight.swift +425 -0
  121. package/ios/Navigation/Component/HeaderTitle.swift +36 -0
  122. package/ios/{Application/Navigation → Navigation/Component}/HeaderUser.swift +21 -24
  123. package/ios/Navigation/Component/SideBar.swift +183 -0
  124. package/ios/{Application/Navigation/Overplay → Navigation/Component}/SnackBar.swift +8 -8
  125. package/ios/Navigation/HostBackGestureBridge.swift +78 -0
  126. package/ios/{Application/Navigation/Overplay → Navigation}/ModalScreen.swift +9 -2
  127. package/ios/Navigation/Navigation.swift +204 -0
  128. package/ios/Navigation/NavigationContainer.swift +195 -0
  129. package/ios/{Application/Navigation → Navigation}/Navigator.swift +207 -107
  130. package/ios/Navigation/StackScreen.swift +934 -0
  131. package/ios/Navigation/StackTransition.swift +400 -0
  132. package/ios/Navigation/SwipeBackHaptic.swift +33 -0
  133. package/ios/{Application/Navigation → Navigation}/Tracking/ScreenTracker.swift +12 -5
  134. package/ios/OTPKeyboard/OTPKeyboard.swift +8 -11
  135. package/ios/Pagination/PaginationScroll.swift +2 -2
  136. package/ios/Popup/PopupDisplay.swift +72 -135
  137. package/ios/Popup/PopupInput.swift +10 -13
  138. package/ios/Popup/PopupLayout.swift +140 -0
  139. package/ios/Popup/PopupNotify.swift +59 -69
  140. package/ios/Popup/PopupPromotion.swift +31 -47
  141. package/ios/PopupView/FullscreenPopup.swift +13 -19
  142. package/ios/PopupView/PopupView.swift +1 -1
  143. package/ios/PopupView/Utils++.swift +0 -8
  144. package/ios/ProgressInfo/ProgressInfo.swift +4 -2
  145. package/ios/Radio/Radio.swift +2 -1
  146. package/ios/Rating/Rating.swift +9 -1
  147. package/ios/Resources/dot_loading.json +558 -0
  148. package/ios/Resources/lottie_circle_loader.json +1 -0
  149. package/ios/ScrollIndicator/ScrollIndicator.swift +11 -10
  150. package/ios/Skeleton/Skeleton.swift +22 -27
  151. package/ios/Slider/Slider.swift +16 -18
  152. package/ios/Stepper/Stepper.swift +6 -1
  153. package/ios/Steps/Steps.swift +8 -6
  154. package/ios/SuggestAction/SuggestAction.swift +5 -0
  155. package/ios/Swipeable/SwipeCell.swift +10 -6
  156. package/ios/Switch/Switch.swift +22 -20
  157. package/ios/TabView/TabView.swift +102 -21
  158. package/ios/Tag/Tag.swift +15 -4
  159. package/ios/Template/Logo/Logo.swift +2 -1
  160. package/ios/Template/TrustBanner/TrustBanner.swift +106 -54
  161. package/ios/Title/Title.swift +6 -17
  162. package/ios/Tooltip/Tooltip.swift +29 -14
  163. package/ios/Typography/FontScale.swift +3 -3
  164. package/ios/Typography/Text.swift +81 -19
  165. package/ios/Uploader/Uploader.swift +3 -1
  166. package/ios/native-kits.podspec +2 -1
  167. package/ios-demo/MoMoUIKitsDemo.podspec +1 -1
  168. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/ContentView.swift +1 -1
  169. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/DuoTabDemo.swift +80 -0
  170. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/Home.swift +2 -0
  171. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/IPhoneDuoDemo.swift +173 -0
  172. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/NavigationDemo.swift +77 -1
  173. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PopupDisplayDemo.swift +1 -0
  174. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PopupNotifyDemo.swift +2 -2
  175. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PopupPromotionDemo.swift +2 -0
  176. package/local.properties +2 -2
  177. package/package.json +1 -1
  178. package/scripts/gen-ios-icons.mjs +36 -0
  179. package/compose/src/commonMain/kotlin/vn/momo/kits/application/AnimationSearchInput.kt +0 -52
  180. package/compose/src/commonMain/kotlin/vn/momo/kits/application/FloatingButton.kt +0 -183
  181. package/compose/src/commonMain/kotlin/vn/momo/kits/application/Header.kt +0 -207
  182. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderAnimated.kt +0 -39
  183. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderBackground.kt +0 -86
  184. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderDefault.kt +0 -76
  185. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderExtended.kt +0 -76
  186. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderRight.kt +0 -293
  187. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderTitle.kt +0 -33
  188. package/compose/src/commonMain/kotlin/vn/momo/kits/application/NavigationContainer.kt +0 -110
  189. package/compose/src/commonMain/kotlin/vn/momo/kits/application/Screen.kt +0 -378
  190. package/compose/src/commonMain/kotlin/vn/momo/kits/application/useHeaderSearchAnimation.kt +0 -69
  191. package/compose/src/commonMain/kotlin/vn/momo/kits/const/KitSizeScale.kt +0 -56
  192. package/example/ios/Example.xcodeproj/xcuserdata/sonnguyen.xcuserdatad/xcschemes/Example.xcscheme +0 -32
  193. package/example/ios/Example.xcodeproj/xcuserdata/sonnguyen.xcuserdatad/xcschemes/xcschememanagement.plist +0 -14
  194. package/example/ios/Example.xcworkspace/xcuserdata/sonnguyen.xcuserdatad/UserInterfaceState.xcuserstate +0 -0
  195. package/example/ios/Example.xcworkspace/xcuserdata/sonnguyen.xcuserdatad/WorkspaceSettings.xcsettings +0 -16
  196. package/example/ios/Example.xcworkspace/xcuserdata/sonnguyen.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist +0 -6
  197. package/example/ios/Example.xcworkspace/xcuserdata/sonnguyen.xcuserdatad/xcschemes/xcschememanagement.plist +0 -5
  198. package/example/ios/Pods/Pods.xcodeproj/xcuserdata/sonnguyen.xcuserdatad/xcschemes/MoMoUIKits.xcscheme +0 -58
  199. package/example/ios/Pods/Pods.xcodeproj/xcuserdata/sonnguyen.xcuserdatad/xcschemes/Pods-Example.xcscheme +0 -58
  200. package/example/ios/Pods/Pods.xcodeproj/xcuserdata/sonnguyen.xcuserdatad/xcschemes/SDWebImage.xcscheme +0 -58
  201. package/example/ios/Pods/Pods.xcodeproj/xcuserdata/sonnguyen.xcuserdatad/xcschemes/SDWebImageSwiftUI.xcscheme +0 -58
  202. package/example/ios/Pods/Pods.xcodeproj/xcuserdata/sonnguyen.xcuserdatad/xcschemes/SkeletonUI.xcscheme +0 -58
  203. package/example/ios/Pods/Pods.xcodeproj/xcuserdata/sonnguyen.xcuserdatad/xcschemes/lottie-ios-LottiePrivacyInfo.xcscheme +0 -58
  204. package/example/ios/Pods/Pods.xcodeproj/xcuserdata/sonnguyen.xcuserdatad/xcschemes/lottie-ios.xcscheme +0 -58
  205. package/example/ios/Pods/Pods.xcodeproj/xcuserdata/sonnguyen.xcuserdatad/xcschemes/xcschememanagement.plist +0 -60
  206. package/ios/Application/Components.swift +0 -270
  207. package/ios/Application/ComposeApi.swift +0 -22
  208. package/ios/Application/HeaderRight.swift +0 -306
  209. package/ios/Application/Navigation/BottomTab/BottomTab.swift +0 -104
  210. package/ios/Application/Navigation/HeaderBackProps.swift +0 -66
  211. package/ios/Application/Navigation/HeaderTitle.swift +0 -57
  212. package/ios/Application/Navigation/NavigationContainer.swift +0 -213
  213. package/ios/Application/Navigation/NavigationOptions.swift +0 -109
  214. package/ios/Application/Navigation/NavigatorManager.swift +0 -22
  215. package/ios/Application/Navigation/component/Header.swift +0 -178
  216. package/ios/Application/Navigation/component/HeaderBackground.swift +0 -83
  217. package/ios/Application/Navigation/component/HeaderColor.swift +0 -65
  218. package/ios/Application/Navigation/component/HeaderRightWidthKey.swift +0 -11
  219. package/ios/Application/Navigation/component/NavigationFooter.swift +0 -90
  220. package/ios/Application/Navigation/component/NavigationHeaderRight.swift +0 -196
  221. package/ios/Application/Screen.swift +0 -250
  222. package/ios/Application/StackScreen.swift +0 -492
  223. /package/ios/{Application/Navigation → Navigation}/BottomTab/CurvedContainer.swift +0 -0
@@ -0,0 +1,934 @@
1
+ //
2
+ // StackScreen.swift
3
+ // MoMoUIKits
4
+ //
5
+ // SwiftUI mirror of Compose's `navigation/StackScreen.kt`: header layer, scrollable content
6
+ // (`StackScreenContent`), animated search, floating button, footer (`NavigationFooter`) and the
7
+ // iPhone Duo side bar.
8
+ //
9
+
10
+ import SwiftUI
11
+
12
+ public struct StackScreen<Content: View>: View {
13
+ // MARK: - Properties
14
+
15
+ var backgroundColor: Color?
16
+ // AI-GENERATED START: custom screen background view (Compose backgroundComponent)
17
+ var background: (() -> AnyView)?
18
+ // AI-GENERATED END: custom screen background view (Compose backgroundComponent)
19
+ var headerTransparent: Bool = false
20
+ var fullScreenContent: Bool = false
21
+ var tintColor: Color? = Colors.black17
22
+ var isBack: Bool
23
+ var headerType: HeaderType
24
+ var useAnimated: Bool = false
25
+ var verticalAlignment: VerticalAlignment
26
+ var horizontalAlignment: HorizontalAlignment
27
+ var title: String
28
+ var headerTitle: HeaderTitle?
29
+ var titlePosition: TitlePosition
30
+ var goBack: (() -> Void)?
31
+ var scrollable: Bool
32
+ var scrollToTopCallback: (() -> Void)?
33
+ var onContentLayout: ((CGRect) -> Void)?
34
+ var useAvoidKeyboard: Bool
35
+ var keyboardShouldPersistTaps: Bool
36
+ var footer: (() -> AnyView)?
37
+ var footerTransparent: Bool
38
+ var footerUseMargin: Bool
39
+ var headerRight: (() -> any View)? = { HeaderRight() }
40
+ var animatedHeader: AnimatedHeader? = nil
41
+ var layoutOffset: CGFloat
42
+ var inputSearchProps: InputSearchProps?
43
+ var fabProps: FabProps?
44
+ var screenName: String = ""
45
+ var content: () -> Content
46
+
47
+ public init(
48
+ backgroundColor: Color? = nil,
49
+ // AI-GENERATED START: custom screen background view (Compose backgroundComponent)
50
+ background: (() -> AnyView)? = nil,
51
+ // AI-GENERATED END: custom screen background view (Compose backgroundComponent)
52
+ headerTransparent: Bool = false,
53
+ fullScreenContent: Bool = false,
54
+ tintColor: Color = Colors.black17,
55
+ isBack: Bool = true,
56
+ headerType: HeaderType = .default,
57
+ useAnimated: Bool = false,
58
+ verticalAlignment: VerticalAlignment = .top,
59
+ horizontalAlignment: HorizontalAlignment = .leading,
60
+ title: String = "Stack",
61
+ headerTitle: HeaderTitle? = nil,
62
+ titlePosition: TitlePosition = .left,
63
+ goBack: (() -> Void)? = nil,
64
+ scrollable: Bool = true,
65
+ scrollToTopCallback: (() -> Void)? = nil,
66
+ onContentLayout: ((CGRect) -> Void)? = nil,
67
+ useAvoidKeyboard: Bool = true,
68
+ keyboardShouldPersistTaps: Bool = false,
69
+ footer: (() -> AnyView)? = nil,
70
+ footerTransparent: Bool = false,
71
+ footerUseMargin: Bool = true,
72
+ headerRight: (() -> any View)? = { HeaderRight() },
73
+ animatedHeader: AnimatedHeader? = nil,
74
+ layoutOffset: CGFloat = 56,
75
+ inputSearchProps: InputSearchProps? = nil,
76
+ fabProps: FabProps? = nil,
77
+ screenName: String = "",
78
+ @ViewBuilder content: @escaping () -> Content
79
+ ) {
80
+ self.backgroundColor = backgroundColor
81
+ // AI-GENERATED START: custom screen background view (Compose backgroundComponent)
82
+ self.background = background
83
+ // AI-GENERATED END: custom screen background view (Compose backgroundComponent)
84
+ self.headerTransparent = headerTransparent
85
+ self.fullScreenContent = fullScreenContent
86
+ self.tintColor = tintColor
87
+ self.isBack = isBack
88
+ self.headerType = headerType
89
+ self.useAnimated = useAnimated
90
+ self.verticalAlignment = verticalAlignment
91
+ self.horizontalAlignment = horizontalAlignment
92
+ self.title = title
93
+ self.headerTitle = headerTitle
94
+ self.titlePosition = titlePosition
95
+ self.goBack = goBack
96
+ self.scrollable = scrollable
97
+ self.scrollToTopCallback = scrollToTopCallback
98
+ self.onContentLayout = onContentLayout
99
+ self.useAvoidKeyboard = useAvoidKeyboard
100
+ self.keyboardShouldPersistTaps = keyboardShouldPersistTaps
101
+ self.footer = footer
102
+ self.footerTransparent = footerTransparent
103
+ self.footerUseMargin = footerUseMargin
104
+ self.headerRight = headerRight
105
+ self.animatedHeader = animatedHeader
106
+ self.layoutOffset = layoutOffset
107
+ self.inputSearchProps = inputSearchProps
108
+ self.fabProps = fabProps
109
+ self.screenName = screenName
110
+ self.content = content
111
+ }
112
+
113
+ @State private var scrollOffset: CGFloat = 0
114
+ @State private var keyboardHeight: CGFloat = 0
115
+ @State private var headerRightWidth: CGFloat = 0
116
+ @State private var isBottomTabRoot: Bool = false
117
+ @State private var headerHide: CGFloat = 0
118
+ @State private var lastScrollOffset: CGFloat = 0
119
+ @State private var footerHeight: CGFloat = 0
120
+
121
+ @Environment(\.applicationEnvironment) private var appEnv
122
+ @Environment(\.screenLayout) private var layout
123
+ @Environment(\.duoTabBarInsets) private var duoTabBarInsets
124
+ @Environment(\.duoBarCompression) private var compression
125
+ @Environment(\.appTheme) private var theme
126
+ @OptionalEnvironmentObject private var navigation: Navigation?
127
+
128
+ @State private var tracking: Task<Void, Never>?
129
+
130
+ /// Compose's `headerOpacity()`: 0 over a banner or transparent header, 1 once the bar is opaque.
131
+ private var opacity: CGFloat {
132
+ headerOpacity(
133
+ scrollOffset: scrollOffset,
134
+ isAnimated: animatedHeader != nil,
135
+ layoutOffset: layoutOffset,
136
+ transparent: headerTransparent
137
+ )
138
+ }
139
+
140
+ private var headerColor: HeaderColor {
141
+ getHeaderColor(
142
+ isAnimated: animatedHeader != nil,
143
+ isSurface: animatedHeader?.isSurface ?? true,
144
+ opacity: opacity,
145
+ tintColor: tintColor
146
+ )
147
+ }
148
+
149
+ // MARK: - Search header (Compose's getInputSearchType / SearchAnimated)
150
+
151
+ private enum InputSearchType { case none, header, animated }
152
+
153
+ private var inputSearchType: InputSearchType {
154
+ guard inputSearchProps != nil, headerType != .none else { return .none }
155
+ return useAnimated ? .animated : .header
156
+ }
157
+
158
+ private var toolkit: (data: HeaderRightData?, tintColor: Color?)? { toolkitHeaderRight(headerRight) }
159
+
160
+ private var showsSideBar: Bool {
161
+ layout.sideBar != nil && headerType != .none && !isBottomTabRoot && (goBack != nil || toolkit != nil)
162
+ }
163
+
164
+ /// `HeaderSideBar` reports its own height; an empty column reports 0, or the rail keeps the previous tab's.
165
+ private func reportEmptySideBar() {
166
+ if layout.sideBar != nil && !showsSideBar { compression.setToolbarHeight(0) }
167
+ }
168
+
169
+ /// iPhone Duo: content scrolls under a glass header instead of starting below it.
170
+ private var useDuoGlass: Bool {
171
+ layout.sideBar != nil && headerType != .none && animatedHeader == nil
172
+ && inputSearchProps == nil && !headerTransparent && !fullScreenContent
173
+ }
174
+
175
+ /// iPhone Duo: the header leaves with the content and slides back in on a reverse scroll
176
+ /// (RN DuoQuickReturnHeader). The side bar is drawn apart from the header, so it stays put.
177
+ private var useQuickReturn: Bool {
178
+ layout.sideBar != nil && scrollable && (useDuoGlass || animatedHeader != nil)
179
+ }
180
+
181
+ private func updateHeaderHide(_ offset: CGFloat) {
182
+ let delta = offset - lastScrollOffset
183
+ lastScrollOffset = offset
184
+ guard useQuickReturn, offset > 0 else {
185
+ if headerHide != 0 { headerHide = 0 }
186
+ return
187
+ }
188
+ headerHide = min(max(headerHide + delta, 0), layout.headerHeight)
189
+ }
190
+
191
+ /// A half-shown header settles fully in or out once the scroll comes to rest.
192
+ private func settleHeader() {
193
+ let height = layout.headerHeight
194
+ // within the first header height the header still sits on the content
195
+ guard useQuickReturn, scrollOffset >= height, headerHide > 0, headerHide < height else { return }
196
+ withAnimation(.easeOut(duration: 0.2)) {
197
+ headerHide = headerHide > height / 2 ? height : 0
198
+ }
199
+ }
200
+
201
+ /// Compose snaps the animated search fully in or out when the scroll stops.
202
+ private var searchSnapDistance: CGFloat? { inputSearchType == .animated ? HEADER_HEIGHT : nil }
203
+
204
+ private var keyboardSize: CGFloat { useAvoidKeyboard ? max(keyboardHeight, 0) : 0 }
205
+
206
+ /// What the footer, tab bar or home indicator takes from the bottom of the window, gap included.
207
+ private var bottomBarsHeight: CGFloat {
208
+ let bars = footer != nil ? footerHeight + NavigationFooter.safeAreaBottom : NavigationFooter.navigationBarInset
209
+ return max(bars + Spacing.XL, duoTabBarInsets.sideBarReserve)
210
+ }
211
+
212
+ /// Top of the footer (or the navigation bar) from the window bottom; snack bars sit above it.
213
+ private var bottomObstruction: CGFloat {
214
+ guard footer != nil else { return keyboardSize + NavigationFooter.navigationBarInset }
215
+ return footerHeight + NavigationFooter.safeAreaBottom - NavigationFooter.verticalOffset(keyboardSize: keyboardSize)
216
+ }
217
+
218
+ private func reportBottomObstruction() {
219
+ // bottom tab children leave overplays to the screen hosting the tabs, as in Compose
220
+ guard let navigation, navigation.bottomTabIndex == -1 else { return }
221
+ navigation.bottomObstruction = bottomObstruction
222
+ }
223
+
224
+ /// Keyboard frames are in screen coordinates; UIScreen stays on the outer display on iPhone Duo.
225
+ private var keyboardReferenceHeight: CGFloat {
226
+ layout.isIPhoneDuo ? layout.height : UIScreen.main.bounds.height
227
+ }
228
+
229
+ /// Room for the animated search below the header at scroll 0; shrinks as the bar slides up.
230
+ private var animatedSearchInset: CGFloat {
231
+ guard inputSearchType == .animated else { return 0 }
232
+ let scroll = max(scrollOffset, 0)
233
+ return min(max(HEADER_HEIGHT - scroll, 0), HEADER_HEIGHT)
234
+ }
235
+
236
+ /// Title fades out as the animated search bar rises into the header.
237
+ private var titleOpacity: CGFloat {
238
+ guard inputSearchType == .animated else { return 1 }
239
+ let scroll = max(scrollOffset, 0)
240
+ return 1 - min(max(scroll / HEADER_HEIGHT, 0), 1)
241
+ }
242
+
243
+ // MARK: - Body
244
+
245
+ public var body: some View {
246
+ GeometryReader { _ in
247
+ ZStack(alignment: .top) {
248
+ // AI-GENERATED START: custom screen background view (Compose backgroundComponent)
249
+ Group {
250
+ if let background {
251
+ background()
252
+ } else {
253
+ backgroundColor ?? theme.colors.background.default
254
+ }
255
+ }
256
+ .edgesIgnoringSafeArea(.all)
257
+ // AI-GENERATED END: custom screen background view (Compose backgroundComponent)
258
+
259
+ VStack(spacing: 0) {
260
+ ZStack(alignment: fabProps?.position == .center ? .bottom : .bottomTrailing) {
261
+ VStack(alignment: horizontalAlignment, spacing: 0) {
262
+ headerAndContent
263
+ }
264
+
265
+ FloatingContent(
266
+ fabProps: fabProps,
267
+ keyboardSize: keyboardSize,
268
+ hasFooter: footer != nil,
269
+ scrollOffset: scrollOffset
270
+ )
271
+ .padding(.bottom, footer == nil ? duoTabBarInsets.overlay : 0)
272
+ }
273
+
274
+ NavigationFooter(
275
+ footerComponent: footer,
276
+ keyboardSize: keyboardSize,
277
+ transparent: footerTransparent,
278
+ useMargin: footerUseMargin
279
+ )
280
+ .padding(.bottom, duoTabBarInsets.overlay)
281
+ .background(
282
+ GeometryReader { proxy in
283
+ Color.clear
284
+ .onAppear { footerHeight = proxy.size.height }
285
+ .onChange(of: proxy.size.height) { footerHeight = $0 }
286
+ }
287
+ )
288
+ }
289
+
290
+ if inputSearchType == .animated, let props = inputSearchProps {
291
+ SearchAnimatedHeader(
292
+ props: props,
293
+ scrollOffset: scrollOffset,
294
+ hiddenBack: goBack == nil,
295
+ hasHeaderRight: headerRight != nil,
296
+ headerRightWidth: headerRightWidth,
297
+ useSideBar: layout.sideBar != nil,
298
+ hasToolkit: toolkit != nil
299
+ )
300
+ }
301
+
302
+ if showsSideBar, let sideBar = layout.sideBar {
303
+ HeaderSideBar(
304
+ sideBar: sideBar,
305
+ goBack: goBack,
306
+ toolkit: toolkit,
307
+ headerColor: headerColor,
308
+ bottomInset: bottomBarsHeight
309
+ )
310
+ }
311
+ }
312
+ .ignoresSafeArea(.keyboard, edges: .bottom)
313
+ .onChange(of: scrollOffset) { updateHeaderHide($0) }
314
+ .onAppear { reportEmptySideBar() }
315
+ .onChange(of: showsSideBar) { _ in reportEmptySideBar() }
316
+ .onPreferenceChange(HeaderRightWidthKey.self) { width in
317
+ if headerRightWidth != width { headerRightWidth = width }
318
+ }
319
+ .onPreferenceChange(IsBottomTabRootKey.self) { isRoot in
320
+ if isBottomTabRoot != isRoot { isBottomTabRoot = isRoot }
321
+ }
322
+ .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillChangeFrameNotification)) { notification in
323
+ guard useAvoidKeyboard,
324
+ let endFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return }
325
+ withAnimation(.easeOut(duration: 0.25)) {
326
+ keyboardHeight = keyboardReferenceHeight - endFrame.origin.y
327
+ }
328
+ }
329
+ // Compose's `hideKeyboardOnTap()`: simultaneous, so taps still reach buttons and fields.
330
+ .modifier(HideKeyboardOnTap(enabled: keyboardShouldPersistTaps))
331
+ }
332
+ .onAppear(perform: reportBottomObstruction)
333
+ .onChange(of: bottomObstruction) { _ in reportBottomObstruction() }
334
+ .onAppear(perform: startTracking)
335
+ .onChange(of: screenName) { _ in startTracking() }
336
+ .onDisappear { tracking?.cancel() }
337
+ }
338
+
339
+ /// Compose tracks from a `LaunchedEffect` with `remember`ed state, so every visit — first show, a
340
+ /// pop back, a tab switch — starts over with fresh state. A covered hosting controller keeps its
341
+ /// `@State` and doesn't re-run `.task`, so the run hangs off appear / disappear instead.
342
+ private func startTracking() {
343
+ tracking?.cancel()
344
+ let state = ScreenTrackingState()
345
+ let action = ScreenTracker.getLastScreenName() != nil ? "push" : "back"
346
+ let maxApi = appEnv.maxApi
347
+ let context = appEnv.applicationContext
348
+ let screenName = screenName
349
+ tracking = Task { @MainActor in
350
+ ScreenTracker.trackScreenNavigated(
351
+ maxApi: maxApi,
352
+ context: context,
353
+ screenName: screenName,
354
+ action: action,
355
+ state: state
356
+ )
357
+ do {
358
+ try await Task.sleep(nanoseconds: 2_000_000_000)
359
+ } catch {
360
+ return
361
+ }
362
+ let loadTime = ScreenTracker.now() - state.time
363
+ ScreenTracker.trackScreenDisplayed(
364
+ maxApi: maxApi,
365
+ context: context,
366
+ screenName: screenName,
367
+ duration: loadTime,
368
+ state: state
369
+ )
370
+ let interactionTime = ScreenTracker.now() - state.time
371
+ ScreenTracker.trackScreenInteracted(
372
+ maxApi: maxApi,
373
+ context: context,
374
+ screenName: screenName,
375
+ duration: interactionTime - loadTime,
376
+ totalDuration: interactionTime,
377
+ state: state
378
+ )
379
+ }
380
+ }
381
+
382
+ // MARK: - Header + content layer
383
+
384
+ @ViewBuilder
385
+ private var headerAndContent: some View {
386
+ if let animatedHeader = animatedHeader {
387
+ // The banner is the top of the scroll content and the body overlaps its lower part;
388
+ // the bar fades in over it (Compose's ScreenContent for HeaderType.Animated).
389
+ ZStack(alignment: .top) {
390
+ StackScreenContent(
391
+ scrollable: scrollable,
392
+ scrollOffset: $scrollOffset,
393
+ scrollToTopCallback: scrollToTopCallback,
394
+ animatedSearchInset: animatedSearchInset,
395
+ bottomInset: duoTabBarInsets.overlay,
396
+ snapDistance: searchSnapDistance,
397
+ onScrollIdle: settleHeader,
398
+ content: { animatedHeroBody(animatedHeader) }
399
+ )
400
+ .edgesIgnoringSafeArea(.all)
401
+
402
+ header
403
+ .background(alignment: .top) {
404
+ Group {
405
+ if layout.sideBar != nil {
406
+ // glass shows once the banner has scrolled under the header
407
+ Color.clear.modifier(DuoBarMaterial(
408
+ shape: Rectangle(),
409
+ visible: scrollOffset > heroGlassStart(animatedHeader)
410
+ ))
411
+ } else {
412
+ HeaderBarBackground(opacity: opacity)
413
+ }
414
+ }
415
+ .edgesIgnoringSafeArea([.top, .horizontal])
416
+ }
417
+ .offset(y: -headerHide)
418
+ .zIndex(100)
419
+ }
420
+ } else if headerTransparent && fullScreenContent {
421
+ // Compose's HeaderType.Transparent(isFullScreenContent): content starts under a clear bar
422
+ ZStack(alignment: .top) {
423
+ header
424
+ .zIndex(100)
425
+
426
+ StackScreenContent(
427
+ scrollable: scrollable,
428
+ scrollOffset: $scrollOffset,
429
+ scrollToTopCallback: scrollToTopCallback,
430
+ animatedSearchInset: animatedSearchInset,
431
+ bottomInset: duoTabBarInsets.overlay,
432
+ snapDistance: searchSnapDistance,
433
+ onScrollIdle: settleHeader,
434
+ content: { duoInsetContent }
435
+ )
436
+ .edgesIgnoringSafeArea(.all)
437
+ }
438
+ } else if useDuoGlass {
439
+ ZStack(alignment: .top) {
440
+ DuoScrollGradient(
441
+ scrollOffset: scrollOffset,
442
+ headerHeight: layout.headerHeight,
443
+ extended: headerType == .extended,
444
+ followScroll: useQuickReturn
445
+ )
446
+
447
+ StackScreenContent(
448
+ scrollable: scrollable,
449
+ scrollOffset: $scrollOffset,
450
+ scrollToTopCallback: scrollToTopCallback,
451
+ topInset: layout.headerHeight - layout.insets.top,
452
+ bottomInset: duoTabBarInsets.overlay,
453
+ snapDistance: searchSnapDistance,
454
+ onScrollIdle: settleHeader,
455
+ content: content
456
+ )
457
+
458
+ header
459
+ .background(
460
+ Color.clear
461
+ .modifier(DuoBarMaterial(shape: Rectangle(), visible: scrollOffset > 0))
462
+ .edgesIgnoringSafeArea([.top, .horizontal])
463
+ )
464
+ .offset(y: -headerHide)
465
+ .zIndex(100)
466
+ }
467
+ } else {
468
+ header
469
+ // top-aligned: an extended band runs past the bar, under the content
470
+ .background(alignment: .top) {
471
+ ZStack(alignment: .top) {
472
+ if layout.sideBar != nil && inputSearchType != .none && !headerTransparent {
473
+ // RN HeaderExtendHeader with search on the side bar layout: the
474
+ // gradient fades out within the header box, as without the search
475
+ DuoScrollGradient(
476
+ scrollOffset: scrollOffset,
477
+ headerHeight: layout.headerHeight,
478
+ extended: headerType == .extended
479
+ )
480
+ } else {
481
+ NavigationHeaderBackground(
482
+ headerType: headerType,
483
+ scrollState: scrollOffset,
484
+ headerTransparent: headerTransparent,
485
+ fullScreenContent: fullScreenContent,
486
+ backgroundColor: backgroundColor
487
+ )
488
+ }
489
+ HeaderBarBackground(opacity: opacity, transparent: headerTransparent || headerType == .none)
490
+ }
491
+ .edgesIgnoringSafeArea([.top, .horizontal])
492
+ }
493
+
494
+ StackScreenContent(
495
+ scrollable: scrollable,
496
+ scrollOffset: $scrollOffset,
497
+ scrollToTopCallback: scrollToTopCallback,
498
+ animatedSearchInset: animatedSearchInset,
499
+ bottomInset: duoTabBarInsets.overlay,
500
+ snapDistance: searchSnapDistance,
501
+ onScrollIdle: settleHeader,
502
+ content: content
503
+ )
504
+ }
505
+ }
506
+
507
+ /// Edge-to-edge branches lay out outside the safe area; iPhone Duo content still keeps
508
+ /// clear of the side strips, as RN's Screen does.
509
+ private var duoInsetContent: some View {
510
+ content()
511
+ .padding(.leading, layout.insetLeft)
512
+ .padding(.trailing, layout.insetRight)
513
+ }
514
+
515
+ private func heroGlassStart(_ animatedHeader: AnimatedHeader) -> CGFloat {
516
+ max(layout.width / animatedHeader.aspectRatio.value - layout.headerHeight, 0)
517
+ }
518
+
519
+ /// The banner fills the width at its aspect ratio; the body starts `layoutOffset` below the
520
+ /// header band so it overlaps the banner's lower part.
521
+ @ViewBuilder
522
+ private func animatedHeroBody(_ animatedHeader: AnimatedHeader) -> some View {
523
+ let width = layout.width
524
+ let imageHeight = width / animatedHeader.aspectRatio.value
525
+ let contentTopInset = layout.headerHeight + layoutOffset
526
+
527
+ ZStack(alignment: .top) {
528
+ animatedHeader.composable(scrollOffset)
529
+ .frame(width: width, height: imageHeight)
530
+ .clipped()
531
+
532
+ duoInsetContent
533
+ .padding(.top, contentTopInset)
534
+ }
535
+ .frame(maxWidth: .infinity, alignment: .top)
536
+ }
537
+
538
+ private var header: NavigationHeader {
539
+ NavigationHeader(
540
+ headerType: headerType,
541
+ titlePosition: titlePosition,
542
+ title: title,
543
+ headerTitle: headerTitle,
544
+ headerRight: headerRight,
545
+ goBack: goBack,
546
+ opacity: opacity,
547
+ animatedHeader: animatedHeader,
548
+ scrollState: scrollOffset,
549
+ // the animated search draws its own field over the (fading) title
550
+ inputSearchProps: inputSearchType == .header ? inputSearchProps : nil,
551
+ tintColor: tintColor,
552
+ titleOpacity: titleOpacity
553
+ )
554
+ }
555
+ }
556
+
557
+ // MARK: - Bottom-tab root preference
558
+
559
+ /// Set by `BottomTab` so the `StackScreen` hosting it keeps the full width and leaves the side
560
+ /// bar to the tabs (Compose's `Navigation.markAsBottomTabRoot()`).
561
+ public struct IsBottomTabRootKey: PreferenceKey {
562
+ public static var defaultValue: Bool = false
563
+ public static func reduce(value: inout Bool, nextValue: () -> Bool) {
564
+ value = value || nextValue()
565
+ }
566
+ }
567
+
568
+ // MARK: - Screen content (scrollable body)
569
+
570
+ /// Compose's `MainContent` / `ScreenContent`: the body, tracking the scroll offset when scrollable.
571
+ private struct StackScreenContent<Content: View>: View {
572
+ let scrollable: Bool
573
+ @Binding var scrollOffset: CGFloat
574
+ /// Compose's `ScrollData.scrollToTopCallback`, fired with the status-bar tap scroll-to-top.
575
+ let scrollToTopCallback: (() -> Void)?
576
+ /// Room for the animated search below the header (Compose's MainContent spacer).
577
+ var animatedSearchInset: CGFloat = 0
578
+ /// Room for a header the content scrolls under (iPhone Duo glass header).
579
+ var topInset: CGFloat = 0
580
+ /// Room for a bar that overlays the content (iPhone Duo floating tab bar).
581
+ var bottomInset: CGFloat = 0
582
+ /// Compose's animated-search snap: a scroll that comes to rest part way through this distance
583
+ /// finishes the move, up or back down.
584
+ var snapDistance: CGFloat? = nil
585
+ var onScrollIdle: (() -> Void)? = nil
586
+ let content: () -> Content
587
+
588
+ private let topAnchorID = "stack_screen_top_anchor"
589
+ private let scrollSpace = "stack_screen_scroll_space"
590
+
591
+ @State private var scrollView = WeakScrollView()
592
+
593
+ private func scrollDidRest() {
594
+ onScrollIdle?()
595
+ guard let snapDistance, scrollOffset > 0, scrollOffset < snapDistance,
596
+ let scrollView = scrollView.value else { return }
597
+ // UIKit, not `ScrollViewProxy.scrollTo`: SwiftUI keeps re-applying a scrollTo target while the
598
+ // content above it resizes, which this content does as the search inset shrinks with the scroll
599
+ let target = scrollOffset < snapDistance / 2 ? 0 : snapDistance
600
+ scrollView.setContentOffset(
601
+ CGPoint(x: scrollView.contentOffset.x, y: target - scrollView.adjustedContentInset.top),
602
+ animated: true
603
+ )
604
+ }
605
+
606
+ var body: some View {
607
+ if scrollable {
608
+ ScrollViewReader { proxy in
609
+ ScrollView(.vertical, showsIndicators: false) {
610
+ VStack(spacing: 0) {
611
+ // Top probe + scroll-to-top anchor, measured in the scroll's own space so the
612
+ // offset is a clean 0-based "points scrolled", independent of the insets below.
613
+ Color.clear
614
+ .frame(height: 0)
615
+ .id(topAnchorID)
616
+ .background(GeometryReader { geo in
617
+ Color.clear
618
+ .onAppear {
619
+ scrollOffset = -geo.frame(in: .named(scrollSpace)).minY
620
+ }
621
+ .onChange(of: geo.frame(in: .named(scrollSpace)).minY) { minY in
622
+ scrollOffset = -minY
623
+ }
624
+ })
625
+ .background(ScrollViewProbe { scrollView.value = $0 })
626
+
627
+ if animatedSearchInset + topInset > 0 {
628
+ Spacer().frame(height: animatedSearchInset + topInset)
629
+ }
630
+
631
+ VStack {
632
+ content()
633
+ }
634
+
635
+ if bottomInset > 0 {
636
+ Spacer().frame(height: bottomInset)
637
+ }
638
+ }
639
+ }
640
+ .coordinateSpace(name: scrollSpace)
641
+ .modifier(ScrollRest(offset: scrollOffset, onRest: scrollDidRest))
642
+ // Status-bar tap (MoMoStatusBarTap swizzle) scrolls to top and notifies the consumer.
643
+ .onReceive(NotificationCenter.default.publisher(for: MoMoStatusBarTap.notificationName)) { _ in
644
+ withAnimation {
645
+ proxy.scrollTo(topAnchorID, anchor: .top)
646
+ }
647
+ scrollToTopCallback?()
648
+ }
649
+ }
650
+ } else {
651
+ content()
652
+ .padding(.top, topInset)
653
+ .padding(.bottom, bottomInset)
654
+ }
655
+ }
656
+ }
657
+
658
+ private final class WeakScrollView {
659
+ weak var value: UIScrollView?
660
+ }
661
+
662
+ /// Finds the UIScrollView behind the enclosing SwiftUI `ScrollView` once placed in its content.
663
+ private struct ScrollViewProbe: UIViewRepresentable {
664
+ let found: (UIScrollView) -> Void
665
+
666
+ func makeUIView(context: Context) -> Probe { Probe(found: found) }
667
+
668
+ func updateUIView(_ uiView: Probe, context: Context) {}
669
+
670
+ final class Probe: UIView {
671
+ let found: (UIScrollView) -> Void
672
+
673
+ init(found: @escaping (UIScrollView) -> Void) {
674
+ self.found = found
675
+ super.init(frame: .zero)
676
+ isUserInteractionEnabled = false
677
+ }
678
+
679
+ required init?(coder: NSCoder) { nil }
680
+
681
+ override func didMoveToWindow() {
682
+ super.didMoveToWindow()
683
+ var view = superview
684
+ while let current = view, !(current is UIScrollView) { view = current.superview }
685
+ if let scrollView = view as? UIScrollView { found(scrollView) }
686
+ }
687
+ }
688
+ }
689
+
690
+ /// Calls `onRest` once the scroll comes to rest. Scroll phases need iOS 18; before that the scroll
691
+ /// counts as ended once the offset stops changing, which a finger held still also looks like.
692
+ private struct ScrollRest: ViewModifier {
693
+ let offset: CGFloat
694
+ let onRest: () -> Void
695
+
696
+ @State private var pending: Task<Void, Never>?
697
+
698
+ func body(content: Content) -> some View {
699
+ #if compiler(>=6.0)
700
+ if #available(iOS 18.0, *) {
701
+ content.onScrollPhaseChange { _, phase in
702
+ if phase == .idle { onRest() }
703
+ }
704
+ } else {
705
+ debounced(content)
706
+ }
707
+ #else
708
+ debounced(content)
709
+ #endif
710
+ }
711
+
712
+ private func debounced(_ content: Content) -> some View {
713
+ content
714
+ .onChange(of: offset) { _ in
715
+ pending?.cancel()
716
+ pending = Task { @MainActor in
717
+ try? await Task.sleep(nanoseconds: 150_000_000)
718
+ guard !Task.isCancelled else { return }
719
+ onRest()
720
+ }
721
+ }
722
+ .onDisappear { pending?.cancel() }
723
+ }
724
+ }
725
+
726
+ // MARK: - Animated search header
727
+
728
+ /// Compose's `SearchAnimated`: the search slides from below the header (scroll 0) up into the bar
729
+ /// while its insets grow to clear the back button and header right.
730
+ private struct SearchAnimatedHeader: View {
731
+ let props: InputSearchProps
732
+ let scrollOffset: CGFloat
733
+ let hiddenBack: Bool
734
+ let hasHeaderRight: Bool
735
+ let headerRightWidth: CGFloat
736
+ var useSideBar: Bool = false
737
+ var hasToolkit: Bool = false
738
+
739
+ @Environment(\.screenLayout) private var layout
740
+ @Environment(\.appTheme) private var theme
741
+ @OptionalEnvironmentObject private var navigator: Navigator?
742
+
743
+ var body: some View {
744
+ let scroll = max(scrollOffset, 0)
745
+
746
+ // Insets are measured from the safe-area top; collapsed, the search is centered in the bar row.
747
+ let rowTop = layout.headerStatusBarHeight - layout.insets.top
748
+ let minTop = rowTop + (layout.headerBarHeight - HEADER_HEIGHT) / 2
749
+ let maxTop = layout.headerHeight - layout.insets.top
750
+ let minSide = Spacing.M
751
+ // collapsed on the iPhone Duo status bar row, the search stays between its clusters (48pt back there)
752
+ let clearance = layout.statusBarClearance
753
+ let backWidth: CGFloat = clearance != nil && !useSideBar ? DuoMetrics.controlSize : 28
754
+ let maxStart = (clearance?.left ?? 0) + (hiddenBack || useSideBar ? Spacing.M : backWidth + Spacing.M + Spacing.S)
755
+ let maxEnd = (clearance?.right ?? 0) + (useSideBar && hasToolkit
756
+ ? Spacing.M
757
+ : headerRightWidth + (hasHeaderRight ? Spacing.M * 2 : Spacing.M))
758
+
759
+ // one progress for all three, so the field docks with its final insets and never covers the toolkit
760
+ let progress = min(scroll / HEADER_HEIGHT, 1)
761
+ let top = maxTop + (minTop - maxTop) * progress
762
+ let start = minSide + (maxStart - minSide) * progress
763
+ let end = minSide + (maxEnd - minSide) * progress
764
+
765
+ // padding, not a Spacer: on the iPhone Duo status bar row the collapsed top is above the safe area
766
+ InputSearch(props: headerSearchProps(
767
+ props,
768
+ scroll: scroll,
769
+ layout: layout,
770
+ theme: theme,
771
+ pop: navigator.map { navigator in { navigator.pop() } }
772
+ ))
773
+ .frame(height: HEADER_HEIGHT)
774
+ .padding(.leading, start)
775
+ .padding(.trailing, end)
776
+ .padding(.top, top)
777
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
778
+ }
779
+ }
780
+
781
+ // MARK: - Floating content
782
+
783
+ /// Compose's `FloatingContent`: no FAB props means nothing renders.
784
+ private struct FloatingContent: View {
785
+ let fabProps: FabProps?
786
+ let keyboardSize: CGFloat
787
+ /// With a footer the FAB is anchored to the footer's *layout* top, which does not move
788
+ /// when the band offsets itself over the keyboard — so it rides the same offset.
789
+ let hasFooter: Bool
790
+ let scrollOffset: CGFloat
791
+
792
+ /// Compose keeps the FAB `Spacing.M + AppNavigationBar` above the bottom of the screen
793
+ /// and lifts it with `imePadding()`; here it sits `Spacing.M` above the safe area and
794
+ /// nothing lifts it, so the difference is applied as an offset (see NavigationFooter).
795
+ private var verticalOffset: CGFloat {
796
+ if hasFooter { return NavigationFooter.verticalOffset(keyboardSize: keyboardSize) }
797
+ return NavigationFooter.safeAreaBottom - NavigationFooter.navigationBarInset - keyboardSize
798
+ }
799
+
800
+ var body: some View {
801
+ if let fabProps = fabProps {
802
+ FloatingButton(
803
+ props: fabProps,
804
+ keyboardOffset: 0,
805
+ scrollOffset: scrollOffset
806
+ )
807
+ .padding(.horizontal, Spacing.M)
808
+ .offset(y: verticalOffset)
809
+ .ignoresSafeArea(.keyboard, edges: .bottom)
810
+ }
811
+ }
812
+ }
813
+
814
+ // MARK: - Footer
815
+
816
+ /// Compose's `Footer`: the band pinned under the content — surface background, S/M padding,
817
+ /// the navigation-bar reserve, and a 6pt shadow cast up onto the content.
818
+ public struct NavigationFooter: View {
819
+ /// Nothing renders when nil.
820
+ var footerComponent: (() -> AnyView)?
821
+ /// Current keyboard height, measured from the bottom of the screen.
822
+ var keyboardSize: CGFloat
823
+ var transparent: Bool
824
+ var useMargin: Bool
825
+
826
+ public init(
827
+ footerComponent: (() -> AnyView)?,
828
+ keyboardSize: CGFloat = 0,
829
+ transparent: Bool = false,
830
+ useMargin: Bool = true
831
+ ) {
832
+ self.footerComponent = footerComponent
833
+ self.keyboardSize = keyboardSize
834
+ self.transparent = transparent
835
+ self.useMargin = useMargin
836
+ }
837
+
838
+ @Environment(\.appTheme) private var theme
839
+ @Environment(\.screenLayout) private var layout
840
+
841
+ /// Read from the window, not from an enclosing `GeometryProxy`: the proxy's bottom
842
+ /// inset grows to the keyboard height while the keyboard is up.
843
+ static var safeAreaBottom: CGFloat {
844
+ UIApplication.shared.connectedScenes
845
+ .compactMap { ($0 as? UIWindowScene)?.keyWindow?.safeAreaInsets.bottom }
846
+ .first ?? 0
847
+ }
848
+
849
+ /// Compose's `AppNavigationBar.current` (`getNavigationBarHeight` clamps iOS to 21pt).
850
+ static var navigationBarInset: CGFloat { min(safeAreaBottom, 21) }
851
+
852
+ /// Compose's `bottomPadding + Spacing.S` with the keyboard down. The footer is laid
853
+ /// out inside the safe area, which already covers part of that gap.
854
+ static var bottomPadding: CGFloat {
855
+ max(navigationBarInset + Spacing.S - safeAreaBottom, 0)
856
+ }
857
+
858
+ /// Deliberately an offset and not padding: growing the footer feeds back into SwiftUI's
859
+ /// keyboard avoidance, so a padding-based lift never settles at the right place.
860
+ /// Negative lifts the band over the keyboard; positive shaves the few points by which
861
+ /// the safe area over-reserves compared to Compose. `FloatingContent` rides the same
862
+ /// amount so the FAB keeps its distance above the band.
863
+ ///
864
+ /// Natural position is `safeAreaBottom + bottomPadding` above the bottom of the screen;
865
+ /// Compose puts it at `(AppNavigationBar - keyboard).coerceAtLeast(0) + Spacing.S` on
866
+ /// top of the `imePadding()` lift — i.e. `max(navigationBar, keyboard) + Spacing.S`.
867
+ static func verticalOffset(keyboardSize: CGFloat) -> CGFloat {
868
+ (safeAreaBottom + bottomPadding) - (max(navigationBarInset, keyboardSize) + Spacing.S)
869
+ }
870
+
871
+ public var body: some View {
872
+ if let footerComponent = footerComponent {
873
+ let margin: CGFloat = useMargin ? Spacing.S : 0
874
+ VStack(alignment: .leading, spacing: 0) {
875
+ footerComponent()
876
+ }
877
+ .frame(maxWidth: .infinity, alignment: .leading)
878
+ .padding(.top, margin)
879
+ .padding(.horizontal, useMargin ? Spacing.M : 0)
880
+ // without the margin the Spacing.S under the content goes too: negative, so the
881
+ // content sinks into the reserve while the frame keeps its bottom edge
882
+ .padding(.bottom, Self.bottomPadding - (Spacing.S - margin))
883
+ // Compose paints the surface all the way to the bottom of the screen; the
884
+ // footer's own frame stops at the safe area, so only the fill bleeds down.
885
+ // With side insets (iPhone Duo) it stays inside them, off the side bar.
886
+ .background(
887
+ (transparent ? Color.clear : theme.colors.background.surface)
888
+ .ignoresSafeArea(.container, edges: hasSideInsets ? .bottom : [.bottom, .horizontal])
889
+ )
890
+ .overlay(alignment: .top) {
891
+ if !transparent { shadow }
892
+ }
893
+ .offset(y: Self.verticalOffset(keyboardSize: keyboardSize))
894
+ }
895
+ }
896
+
897
+ private var hasSideInsets: Bool { layout.insetLeft > 0 || layout.insetRight > 0 }
898
+
899
+ /// Compose offsets the shadow box by -6dp, so it lands on the content above the
900
+ /// footer rather than on the footer surface itself.
901
+ private var shadow: some View {
902
+ LinearGradient(
903
+ colors: [Color.clear, Color.black.opacity(0.05)],
904
+ startPoint: .top,
905
+ endPoint: .bottom
906
+ )
907
+ .frame(height: 6)
908
+ .offset(y: -6)
909
+ .allowsHitTesting(false)
910
+ }
911
+ }
912
+
913
+ // MARK: - Hide keyboard on tap
914
+
915
+ /// Compose's `Modifier.hideKeyboardOnTap()`. Uses `simultaneousGesture` so the tap is not
916
+ /// consumed — buttons and text fields keep working.
917
+ private struct HideKeyboardOnTap: ViewModifier {
918
+ let enabled: Bool
919
+
920
+ func body(content: Content) -> some View {
921
+ if enabled {
922
+ content.simultaneousGesture(
923
+ TapGesture().onEnded {
924
+ UIApplication.shared.sendAction(
925
+ #selector(UIResponder.resignFirstResponder),
926
+ to: nil, from: nil, for: nil
927
+ )
928
+ }
929
+ )
930
+ } else {
931
+ content
932
+ }
933
+ }
934
+ }