@momo-kits/native-kits 0.162.44-debug → 0.162.45-debug

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (199) hide show
  1. package/CLAUDE.md +45 -3
  2. package/README.md +2 -2
  3. package/compose/build.gradle.kts +9 -1
  4. package/compose/build.gradle.kts.backup +8 -0
  5. package/compose/compose.podspec +1 -1
  6. package/compose/src/androidMain/kotlin/vn/momo/kits/platform/Platform.android.kt +2 -0
  7. package/compose/src/androidMain/kotlin/vn/momo/kits/platform/ReservedRegions.android.kt +6 -0
  8. package/compose/src/commonMain/kotlin/vn/momo/kits/application/LiteScreen.kt +351 -111
  9. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Badge.kt +20 -21
  10. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Button.kt +2 -1
  11. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Carousel.kt +4 -2
  12. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Chip.kt +1 -1
  13. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Icon.kt +1 -1
  14. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Information.kt +1 -1
  15. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Input.kt +9 -6
  16. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputMoney.kt +11 -12
  17. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputOTP.kt +4 -9
  18. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputPhoneNumber.kt +2 -2
  19. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputSearch.kt +10 -9
  20. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputTextArea.kt +6 -6
  21. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PopupNotify.kt +55 -22
  22. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PopupPromotion.kt +12 -1
  23. package/compose/src/commonMain/kotlin/vn/momo/kits/components/ProgressInfo.kt +7 -9
  24. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Radio.kt +1 -1
  25. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Slider.kt +13 -6
  26. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Swipe.kt +13 -5
  27. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Switch.kt +3 -2
  28. package/compose/src/commonMain/kotlin/vn/momo/kits/components/TabView.kt +14 -6
  29. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Tag.kt +19 -21
  30. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Text.kt +2 -2
  31. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Tooltip.kt +5 -2
  32. package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/DateTimePickerUtils.kt +3 -1
  33. package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/WheelPicker.kt +7 -15
  34. package/compose/src/commonMain/kotlin/vn/momo/kits/const/Typography.kt +2 -1
  35. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/Item.kt +1 -2
  36. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/ScreenLayout.kt +314 -0
  37. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/BottomSheet.kt +43 -4
  38. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/ModalScreen.kt +8 -0
  39. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigation.kt +10 -0
  40. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/NavigationContainer.kt +4 -0
  41. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigator.kt +6 -0
  42. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/StackScreen.kt +197 -60
  43. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTab.kt +127 -83
  44. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTabBar.kt +54 -62
  45. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/DuoBarCompression.kt +97 -0
  46. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/DuoTabBar.kt +377 -0
  47. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/Header.kt +122 -24
  48. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderBackground.kt +34 -14
  49. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderJourney.kt +3 -3
  50. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderLocation.kt +1 -2
  51. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderRight.kt +116 -26
  52. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderUser.kt +1 -2
  53. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/SideBar.kt +192 -0
  54. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/SnackBar.kt +2 -1
  55. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/Platform.kt +3 -0
  56. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/ReservedRegions.kt +13 -0
  57. package/compose/src/commonMain/kotlin/vn/momo/kits/utils/Utils.kt +13 -0
  58. package/compose/src/iosMain/kotlin/vn/momo/kits/platform/Platform.ios.kt +4 -0
  59. package/compose/src/iosMain/kotlin/vn/momo/kits/platform/ReservedRegions.ios.kt +94 -0
  60. package/gradle/libs.versions.toml +1 -1
  61. package/gradle.properties +1 -1
  62. package/ios/Application/ApplicationEnvironment.swift +17 -0
  63. package/ios/Application/MaxApi.swift +30 -3
  64. package/ios/Avatar/Avatar.swift +4 -3
  65. package/ios/Badge/Badge.swift +6 -4
  66. package/ios/Badge/BadgeDot.swift +1 -1
  67. package/ios/Badge/BadgeRibbon.swift +0 -1
  68. package/ios/Button/Button.swift +15 -12
  69. package/ios/CalculatorKeyboard/CalculatorKeyboard.swift +64 -18
  70. package/ios/Carousel/Carousel.swift +29 -12
  71. package/ios/Checkbox/Checkbox.swift +7 -3
  72. package/ios/Chip/Chip.swift +5 -4
  73. package/ios/Collapse/Collapse.swift +70 -19
  74. package/ios/Colors+Radius+Spacing/Colors.swift +10 -1
  75. package/ios/Colors+Radius+Spacing/Radius.swift +6 -0
  76. package/ios/DateTimePicker/DateTimePicker.swift +54 -20
  77. package/ios/DateTimePicker/WheelPicker.swift +103 -105
  78. package/ios/Divider/Divider.swift +37 -4
  79. package/ios/Extensions/Color++.swift +38 -9
  80. package/ios/Extensions/OptionalEnvironmentObject.swift +12 -0
  81. package/ios/Extensions/SkeletonUICompat.swift +11 -0
  82. package/ios/Icon/Icon.swift +21 -23
  83. package/ios/Icon/Icons.swift +1329 -0
  84. package/ios/IconButton/IconButton.swift +15 -7
  85. package/ios/Image/Image.swift +7 -23
  86. package/ios/Information/Information.swift +27 -9
  87. package/ios/Input/ErrorView.swift +2 -2
  88. package/ios/Input/Input.swift +160 -147
  89. package/ios/Input/InputPhoneNumber.swift +12 -9
  90. package/ios/Input/InputSearch.swift +107 -77
  91. package/ios/Input/InputTextArea.swift +128 -145
  92. package/ios/InputDropDown/InputDropDown.swift +10 -7
  93. package/ios/InputMoney/InputMoney.swift +33 -11
  94. package/ios/InputOTP/InputOTP.swift +22 -12
  95. package/ios/Layout/Card.swift +50 -0
  96. package/ios/Layout/GridContext.swift +187 -3
  97. package/ios/Layout/Item.swift +5 -4
  98. package/ios/Layout/ScreenLayout.swift +385 -0
  99. package/ios/Layout/Section.swift +4 -100
  100. package/ios/Loader/Loader.swift +5 -9
  101. package/ios/Lottie/LottieView.swift +14 -2
  102. package/ios/{Application/Navigation/Overplay → Navigation}/BottomSheet.swift +169 -29
  103. package/ios/Navigation/BottomTab/BottomTab.swift +332 -0
  104. package/ios/{Application/Navigation → Navigation}/BottomTab/BottomTabBar.swift +18 -7
  105. package/ios/Navigation/BottomTab/DuoBarCompression.swift +104 -0
  106. package/ios/Navigation/BottomTab/DuoTabBar.swift +329 -0
  107. package/ios/{Application → Navigation/Component}/FloatingButton.swift +43 -54
  108. package/ios/Navigation/Component/Header.swift +545 -0
  109. package/ios/Navigation/Component/HeaderBackground.swift +154 -0
  110. package/ios/Navigation/Component/HeaderJourney.swift +73 -0
  111. package/ios/Navigation/Component/HeaderLocation.swift +48 -0
  112. package/ios/Navigation/Component/HeaderRight.swift +425 -0
  113. package/ios/Navigation/Component/HeaderTitle.swift +36 -0
  114. package/ios/{Application/Navigation → Navigation/Component}/HeaderUser.swift +21 -24
  115. package/ios/Navigation/Component/SideBar.swift +183 -0
  116. package/ios/{Application/Navigation/Overplay → Navigation/Component}/SnackBar.swift +8 -8
  117. package/ios/Navigation/HostBackGestureBridge.swift +78 -0
  118. package/ios/{Application/Navigation/Overplay → Navigation}/ModalScreen.swift +9 -2
  119. package/ios/Navigation/Navigation.swift +204 -0
  120. package/ios/Navigation/NavigationContainer.swift +195 -0
  121. package/ios/{Application/Navigation → Navigation}/Navigator.swift +207 -107
  122. package/ios/Navigation/StackScreen.swift +930 -0
  123. package/ios/Navigation/StackTransition.swift +400 -0
  124. package/ios/Navigation/SwipeBackHaptic.swift +33 -0
  125. package/ios/{Application/Navigation → Navigation}/Tracking/ScreenTracker.swift +12 -5
  126. package/ios/OTPKeyboard/OTPKeyboard.swift +8 -11
  127. package/ios/Pagination/PaginationScroll.swift +2 -2
  128. package/ios/Popup/PopupDisplay.swift +72 -135
  129. package/ios/Popup/PopupInput.swift +10 -13
  130. package/ios/Popup/PopupLayout.swift +140 -0
  131. package/ios/Popup/PopupNotify.swift +59 -69
  132. package/ios/Popup/PopupPromotion.swift +31 -47
  133. package/ios/PopupView/FullscreenPopup.swift +13 -19
  134. package/ios/PopupView/PopupView.swift +1 -1
  135. package/ios/PopupView/Utils++.swift +0 -8
  136. package/ios/ProgressInfo/ProgressInfo.swift +4 -2
  137. package/ios/Radio/Radio.swift +2 -1
  138. package/ios/Rating/Rating.swift +9 -1
  139. package/ios/Resources/dot_loading.json +558 -0
  140. package/ios/Resources/lottie_circle_loader.json +1 -0
  141. package/ios/ScrollIndicator/ScrollIndicator.swift +11 -10
  142. package/ios/Skeleton/Skeleton.swift +22 -27
  143. package/ios/Slider/Slider.swift +16 -18
  144. package/ios/Stepper/Stepper.swift +6 -1
  145. package/ios/Steps/Steps.swift +8 -6
  146. package/ios/SuggestAction/SuggestAction.swift +5 -0
  147. package/ios/Swipeable/SwipeCell.swift +10 -6
  148. package/ios/Switch/Switch.swift +22 -20
  149. package/ios/TabView/TabView.swift +102 -21
  150. package/ios/Tag/Tag.swift +15 -4
  151. package/ios/Template/Logo/Logo.swift +2 -1
  152. package/ios/Template/TrustBanner/TrustBanner.swift +106 -54
  153. package/ios/Title/Title.swift +6 -17
  154. package/ios/Tooltip/Tooltip.swift +29 -14
  155. package/ios/Typography/FontScale.swift +3 -3
  156. package/ios/Typography/Text.swift +81 -19
  157. package/ios/Uploader/Uploader.swift +3 -1
  158. package/ios/native-kits.podspec +2 -1
  159. package/ios-demo/MoMoUIKitsDemo.podspec +1 -1
  160. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/ContentView.swift +1 -1
  161. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/DuoTabDemo.swift +80 -0
  162. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/Home.swift +2 -0
  163. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/IPhoneDuoDemo.swift +173 -0
  164. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/NavigationDemo.swift +77 -1
  165. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PopupDisplayDemo.swift +1 -0
  166. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PopupNotifyDemo.swift +2 -2
  167. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PopupPromotionDemo.swift +2 -0
  168. package/package.json +1 -1
  169. package/scripts/gen-ios-icons.mjs +36 -0
  170. package/compose/src/commonMain/kotlin/vn/momo/kits/application/AnimationSearchInput.kt +0 -52
  171. package/compose/src/commonMain/kotlin/vn/momo/kits/application/FloatingButton.kt +0 -183
  172. package/compose/src/commonMain/kotlin/vn/momo/kits/application/Header.kt +0 -207
  173. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderAnimated.kt +0 -39
  174. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderBackground.kt +0 -86
  175. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderDefault.kt +0 -76
  176. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderExtended.kt +0 -76
  177. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderRight.kt +0 -293
  178. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderTitle.kt +0 -33
  179. package/compose/src/commonMain/kotlin/vn/momo/kits/application/NavigationContainer.kt +0 -110
  180. package/compose/src/commonMain/kotlin/vn/momo/kits/application/Screen.kt +0 -378
  181. package/compose/src/commonMain/kotlin/vn/momo/kits/application/useHeaderSearchAnimation.kt +0 -69
  182. package/ios/Application/Components.swift +0 -270
  183. package/ios/Application/ComposeApi.swift +0 -22
  184. package/ios/Application/HeaderRight.swift +0 -306
  185. package/ios/Application/Navigation/BottomTab/BottomTab.swift +0 -104
  186. package/ios/Application/Navigation/HeaderBackProps.swift +0 -66
  187. package/ios/Application/Navigation/HeaderTitle.swift +0 -57
  188. package/ios/Application/Navigation/NavigationContainer.swift +0 -213
  189. package/ios/Application/Navigation/NavigationOptions.swift +0 -109
  190. package/ios/Application/Navigation/NavigatorManager.swift +0 -22
  191. package/ios/Application/Navigation/component/Header.swift +0 -178
  192. package/ios/Application/Navigation/component/HeaderBackground.swift +0 -83
  193. package/ios/Application/Navigation/component/HeaderColor.swift +0 -65
  194. package/ios/Application/Navigation/component/HeaderRightWidthKey.swift +0 -11
  195. package/ios/Application/Navigation/component/NavigationFooter.swift +0 -90
  196. package/ios/Application/Navigation/component/NavigationHeaderRight.swift +0 -196
  197. package/ios/Application/Screen.swift +0 -250
  198. package/ios/Application/StackScreen.swift +0 -492
  199. /package/ios/{Application/Navigation → Navigation}/BottomTab/CurvedContainer.swift +0 -0
@@ -20,6 +20,7 @@ import androidx.compose.foundation.layout.offset
20
20
  import androidx.compose.foundation.layout.padding
21
21
  import androidx.compose.foundation.layout.size
22
22
  import androidx.compose.foundation.layout.width
23
+ import androidx.compose.foundation.layout.absolutePadding
23
24
  import androidx.compose.foundation.shape.RoundedCornerShape
24
25
  import androidx.compose.runtime.Composable
25
26
  import androidx.compose.runtime.DisposableEffect
@@ -51,6 +52,10 @@ import vn.momo.kits.modifier.conditional
51
52
  import vn.momo.kits.modifier.noFeedbackClickable
52
53
  import vn.momo.kits.platform.BackHandler
53
54
  import vn.momo.kits.platform.getScreenHeight
55
+ import vn.momo.kits.layout.getScreenLayout
56
+
57
+ // iPhone Duo inner display: center the sheet instead of spanning the width (RN BottomSheet).
58
+ private val DUO_REGULAR_MIN_WIDTH = 600.dp
54
59
 
55
60
  @Composable
56
61
  internal fun BottomSheet(
@@ -63,7 +68,11 @@ internal fun BottomSheet(
63
68
  footerComponent: (@Composable () -> Unit)? = null,
64
69
  onDismiss: (() -> Unit)?
65
70
  ) {
66
- val screenHeightDp = getScreenHeight()
71
+ val layout = getScreenLayout()
72
+ val isDuo = layout.isIPhoneDuoDevice
73
+ val isDuoCentered = isDuo && layout.width >= DUO_REGULAR_MIN_WIDTH
74
+ // the screen is the outer display on iPhone Duo, even when unfolded
75
+ val screenHeightDp = if (isDuo) layout.height else getScreenHeight()
67
76
  val screenHeightPx = with(LocalDensity.current) { screenHeightDp.toPx() }
68
77
  val sheetOffset = remember { Animatable(screenHeightPx) }
69
78
 
@@ -148,13 +157,32 @@ internal fun BottomSheet(
148
157
  }
149
158
  ) {
150
159
  val parentHeight = with(LocalDensity.current) { constraints.maxHeight.toDp() }
160
+ val maxHeight = if (isDuo) {
161
+ parentHeight - maxOf(layout.duoTopInset, Spacing.XL)
162
+ } else {
163
+ parentHeight - 90.dp
164
+ }
165
+ // landscape side buttons span the full height; in portrait the side status bar only covers the top
166
+ val sideInset = isDuo && !isDuoCentered && layout.width > layout.height
151
167
 
152
168
  Box(
153
169
  modifier = Modifier
154
170
  .align(Alignment.BottomCenter)
155
171
  .offset { IntOffset(x = 0, y = sheetOffset.value.toInt()) }
156
- .fillMaxWidth()
157
- .heightIn(max = parentHeight - 90.dp)
172
+ .then(
173
+ if (isDuoCentered) {
174
+ // keeps the portrait width in landscape, like system sheets
175
+ Modifier.width(
176
+ minOf(
177
+ minOf(layout.width, layout.height) - Spacing.L * 2,
178
+ layout.width - maxOf(layout.insetLeft, layout.insetRight, Spacing.L) * 2,
179
+ )
180
+ )
181
+ } else {
182
+ Modifier.fillMaxWidth()
183
+ }
184
+ )
185
+ .heightIn(max = maxHeight)
158
186
  .background(
159
187
  color = if (isSurface) AppTheme.current.colors.background.surface else AppTheme.current.colors.background.default,
160
188
  shape = RoundedCornerShape(topStart = Radius.M, topEnd = Radius.M)
@@ -168,7 +196,13 @@ internal fun BottomSheet(
168
196
  val bottomInset = if (useBottomInset) {
169
197
  (AppNavigationBar.current - keyboardSizeState().value).coerceAtLeast(0.dp)
170
198
  } else 0.dp
171
- Column(Modifier.padding(bottom = bottomInset)) {
199
+ Column(
200
+ Modifier
201
+ .padding(bottom = bottomInset)
202
+ .conditional(sideInset) {
203
+ absolutePadding(left = layout.insetLeft, right = layout.insetRight)
204
+ }
205
+ ) {
172
206
  Box(
173
207
  modifier = Modifier
174
208
  .height(72.dp)
@@ -249,6 +283,11 @@ internal fun BottomSheet(
249
283
  content()
250
284
  }
251
285
  BottomSheetFooter(footerComponent)
286
+ } else if (isDuo) {
287
+ // content shrinks so a tall sheet keeps its header
288
+ Box(Modifier.weight(1f, fill = false)) {
289
+ content()
290
+ }
252
291
  } else {
253
292
  content()
254
293
  }
@@ -9,6 +9,7 @@ import androidx.compose.foundation.clickable
9
9
  import androidx.compose.foundation.interaction.MutableInteractionSource
10
10
  import androidx.compose.foundation.layout.Box
11
11
  import androidx.compose.foundation.layout.fillMaxSize
12
+ import androidx.compose.foundation.layout.padding
12
13
  import androidx.compose.foundation.layout.wrapContentSize
13
14
  import androidx.compose.runtime.*
14
15
  import androidx.compose.ui.Alignment
@@ -25,6 +26,7 @@ import vn.momo.kits.modifier.conditional
25
26
  import vn.momo.kits.modifier.noFeedbackClickable
26
27
  import vn.momo.kits.navigation.tracking.ScreenTracker
27
28
  import vn.momo.kits.platform.BackHandler
29
+ import vn.momo.kits.layout.getScreenLayout
28
30
 
29
31
  @Composable
30
32
  internal fun ModalScreen(
@@ -100,12 +102,18 @@ internal fun ModalScreen(
100
102
  closeEvent()
101
103
  }
102
104
 
105
+ val layout = getScreenLayout()
106
+
103
107
  Box(
104
108
  modifier = Modifier
105
109
  .fillMaxSize()
106
110
  .background(Color.Black.copy(alpha = 0.6f * alpha.value))
107
111
  .noFeedbackClickable {
108
112
  if(barrierDismissible) closeEvent()
113
+ }
114
+ // center below the side status bar and above the home indicator
115
+ .conditional(layout.isIPhoneDuoDevice) {
116
+ padding(top = layout.duoTopInset, bottom = layout.insetBottom)
109
117
  },
110
118
  contentAlignment = Alignment.Center
111
119
  ) {
@@ -30,6 +30,9 @@ class Navigation(
30
30
  headerType: HeaderType? = null,
31
31
  scrollData: ScrollData? = null,
32
32
  backgroundColor: Color? = null,
33
+ // AI-GENERATED START: custom screen background
34
+ backgroundComponent: (@Composable () -> Unit)? = null,
35
+ // AI-GENERATED END: custom screen background
33
36
  tintColor: Color? = null,
34
37
  footerComponent: (@Composable () -> Unit)? = null,
35
38
  footerTransparent: Boolean? = null,
@@ -48,6 +51,9 @@ class Navigation(
48
51
  headerType = headerType ?: options.headerType,
49
52
  scrollData = scrollData ?: options.scrollData,
50
53
  backgroundColor = backgroundColor ?: options.backgroundColor,
54
+ // AI-GENERATED START: custom screen background
55
+ backgroundComponent = backgroundComponent ?: options.backgroundComponent,
56
+ // AI-GENERATED END: custom screen background
51
57
  tintColor = tintColor ?: options.tintColor,
52
58
  footerComponent = footerComponent ?: options.footerComponent,
53
59
  footerTransparent = footerTransparent ?: options.footerTransparent,
@@ -85,6 +91,10 @@ data class NavigationOptions(
85
91
  val headerType: HeaderType = HeaderType.Default(),
86
92
  val scrollData: ScrollData = ScrollData(),
87
93
  val backgroundColor: Color? = null,
94
+ // AI-GENERATED START: custom screen background (SwiftUI `NavigationOptions.background`)
95
+ /** Drawn instead of `backgroundColor` over the whole screen: under the header, outside the keyboard padding. */
96
+ val backgroundComponent: @Composable (() -> Unit)? = null,
97
+ // AI-GENERATED END: custom screen background (SwiftUI `NavigationOptions.background`)
88
98
  val tintColor: Color? = null,
89
99
  val footerComponent: @Composable (() -> Unit)? = null,
90
100
  val footerTransparent: Boolean = false,
@@ -31,6 +31,8 @@ import vn.momo.kits.const.AppStatusBar
31
31
  import vn.momo.kits.const.AppTheme
32
32
  import vn.momo.kits.const.Theme
33
33
  import vn.momo.kits.const.defaultTheme
34
+ import vn.momo.kits.layout.LocalScreenLayout
35
+ import vn.momo.kits.layout.calculateScreenLayout
34
36
  import vn.momo.kits.platform.ProvideNavigationEventDispatcherOwner
35
37
  import vn.momo.kits.platform.ProvideOsFontScale
36
38
  import vn.momo.kits.utils.getAppStatusBarHeight
@@ -56,6 +58,7 @@ fun NavigationContainer(
56
58
  val navigator = remember { Navigator(navController = navController, maxApi = maxApi, registry = registry) }
57
59
  val statusBarHeight = statusBarHeight ?: getAppStatusBarHeight()
58
60
  val navigationBarHeight = getNavigationBarHeight()
61
+ val screenLayout = calculateScreenLayout(statusBarHeight, parent = LocalScreenLayout.current)
59
62
 
60
63
  val parentContext = LocalContext.current
61
64
  val mergedContext = MiniAppContext.merge(parentContext, miniAppContext)
@@ -101,6 +104,7 @@ fun NavigationContainer(
101
104
  AppTheme provides resolvedTheme,
102
105
  AppStatusBar provides statusBarHeight,
103
106
  AppNavigationBar provides navigationBarHeight,
107
+ LocalScreenLayout provides screenLayout,
104
108
  LocalContext provides mergedContext,
105
109
  LocalDesignSystemConfig provides resolvedDesignSystemConfig,
106
110
  LocalLocalize provides resolvedLocalize,
@@ -55,6 +55,12 @@ class Navigator(
55
55
  }
56
56
 
57
57
  fun onBackSafe(callBack: (() -> Unit)? = null) {
58
+ // An open modal or bottom sheet takes the back first, as its BackHandler does for gestures.
59
+ val overplayType = OverplayComponentRegistry.getOverplayType()
60
+ if (overplayType != null && overplayType != OverplayComponentType.SNACK_BAR) {
61
+ pop(1, callBack)
62
+ return
63
+ }
58
64
  tabBackHandler?.let {
59
65
  it()
60
66
  return
@@ -1,6 +1,9 @@
1
1
  package vn.momo.kits.navigation
2
2
 
3
3
  import androidx.compose.animation.animateColorAsState
4
+ import androidx.compose.animation.core.Animatable
5
+ import androidx.compose.animation.core.AnimationVector1D
6
+ import androidx.compose.animation.core.tween
4
7
  import androidx.compose.foundation.ScrollState
5
8
  import androidx.compose.foundation.background
6
9
  import androidx.compose.foundation.border
@@ -39,6 +42,7 @@ import androidx.compose.ui.Alignment
39
42
  import androidx.compose.ui.Modifier
40
43
  import androidx.compose.ui.graphics.Brush
41
44
  import androidx.compose.ui.graphics.Color
45
+ import androidx.compose.ui.graphics.graphicsLayer
42
46
  import androidx.compose.ui.graphics.lerp
43
47
  import androidx.compose.ui.layout.onGloballyPositioned
44
48
  import androidx.compose.ui.platform.LocalDensity
@@ -51,7 +55,6 @@ import kotlinx.coroutines.flow.filter
51
55
  import kotlinx.coroutines.launch
52
56
  import vn.momo.kits.components.InputSearch
53
57
  import vn.momo.kits.const.AppNavigationBar
54
- import vn.momo.kits.const.AppStatusBar
55
58
  import vn.momo.kits.const.AppTheme
56
59
  import vn.momo.kits.application.IsShowBaseLineDebug
57
60
  import vn.momo.kits.const.Colors
@@ -65,14 +68,24 @@ import vn.momo.kits.navigation.component.HEADER_HEIGHT
65
68
  import vn.momo.kits.navigation.component.Header
66
69
  import vn.momo.kits.navigation.component.HeaderBackground
67
70
  import vn.momo.kits.navigation.component.HeaderRight
71
+ import vn.momo.kits.navigation.component.HeaderSideBar
72
+ import vn.momo.kits.navigation.bottomtab.LocalDuoRailReserve
73
+ import vn.momo.kits.navigation.bottomtab.duoFloatingTabBarInset
68
74
  import vn.momo.kits.navigation.component.HeaderType
69
75
  import vn.momo.kits.navigation.component.InputSearchType
76
+ import vn.momo.kits.navigation.bottomtab.LocalBottomTabFocused
70
77
  import vn.momo.kits.platform.BackHandler
71
78
  import vn.momo.kits.platform.supportsImePadding
72
79
  import vn.momo.kits.navigation.tracking.ScreenTracker
73
80
  import vn.momo.kits.navigation.tracking.ScreenTrackingState
74
81
  import kotlinx.coroutines.delay
75
82
  import vn.momo.kits.application.LocalContext
83
+ import vn.momo.kits.utils.horizontalSafeAreaPadding
84
+ import vn.momo.kits.layout.LocalScreenLayout
85
+ import vn.momo.kits.layout.ScreenLayout
86
+ import vn.momo.kits.layout.consumeHorizontalInsets
87
+ import vn.momo.kits.layout.DuoMetrics
88
+ import vn.momo.kits.layout.getScreenLayout
76
89
  import kotlin.time.Clock
77
90
  import kotlin.time.Duration.Companion.milliseconds
78
91
  import kotlin.time.ExperimentalTime
@@ -80,11 +93,19 @@ import kotlin.time.ExperimentalTime
80
93
  internal val LocalFooterHeightPx = staticCompositionLocalOf { mutableIntStateOf(0) }
81
94
  internal val LocalHeaderRightWidthPx = staticCompositionLocalOf { mutableIntStateOf(0) }
82
95
 
96
+ // Space a tab's content leaves for the tab bar and home indicator. On iPhone Duo the floating bar
97
+ // overlays the content and the rail lives in the side strip, so only their own inset applies.
83
98
  @Composable
84
- private fun bottomTabBarInset(): Dp {
85
- if (LocalNavigation.current.bottomTabIndex == -1) return 0.dp
99
+ private fun bottomTabChildInset(): Dp {
86
100
  val keyboard = keyboardSizeState().value
87
- return (BOTTOM_TAB_BAR_HEIGHT.dp + Spacing.S - keyboard).coerceAtLeast(0.dp)
101
+ val navigationBar = AppNavigationBar.current
102
+ val layout = getScreenLayout()
103
+ return when {
104
+ layout.isIPhoneDuo && layout.sideBar == null -> (duoFloatingTabBarInset(layout) - keyboard).coerceAtLeast(0.dp)
105
+ layout.isIPhoneDuo -> (navigationBar - keyboard).coerceAtLeast(0.dp)
106
+ else -> (BOTTOM_TAB_BAR_HEIGHT.dp + Spacing.S - keyboard).coerceAtLeast(0.dp) +
107
+ (navigationBar - keyboard).coerceAtLeast(0.dp)
108
+ }
88
109
  }
89
110
 
90
111
  @OptIn(ExperimentalMaterialApi::class, ExperimentalTime::class)
@@ -100,7 +121,7 @@ internal fun StackScreen(
100
121
  val navigator = LocalNavigator.current
101
122
  val maxApi = LocalMaxApi.current
102
123
  val context = LocalContext.current
103
- val statusBar = AppStatusBar.current
124
+ val screenLayout = getScreenLayout()
104
125
  val density = LocalDensity.current
105
126
  val screenRegistry = LocalDynamicScreenRegistry.current
106
127
  val navigation = remember { Navigation(id = id, bottomTabIndex = bottomTabIndex, initOptions = navigationOptions, registry = screenRegistry) }
@@ -109,20 +130,24 @@ internal fun StackScreen(
109
130
  val isBottomTabChild = bottomTabIndex != -1
110
131
  val isBottomTabRoot = navigation.isBottomTabRoot
111
132
 
133
+ // A mounted-but-hidden bottom tab tracks again each time it is focused (RN useFocusEffect).
134
+ val tabFocused = LocalBottomTabFocused.current
135
+
112
136
  // Auto tracking state
113
- val trackingState = remember {
137
+ val trackingState = remember(tabFocused) {
114
138
  ScreenTrackingState().apply {
115
139
  time = Clock.System.now().toEpochMilliseconds()
116
140
  }
117
141
  }
118
142
 
119
143
  // Determine action: push or back
120
- val action = remember {
144
+ val action = remember(tabFocused) {
121
145
  if (ScreenTracker.getLastScreenName() != null) "push" else "back"
122
146
  }
123
147
 
124
148
  // Auto tracking effects
125
- LaunchedEffect(name) {
149
+ LaunchedEffect(name, tabFocused) {
150
+ if (!tabFocused) return@LaunchedEffect
126
151
  // Track screen navigated immediately
127
152
  ScreenTracker.trackScreenNavigated(
128
153
  maxApi = maxApi,
@@ -156,7 +181,7 @@ internal fun StackScreen(
156
181
  }
157
182
 
158
183
  val limit = with(density) {
159
- (statusBar + HEADER_HEIGHT.dp).toPx()
184
+ screenLayout.headerHeight.toPx()
160
185
  }.toInt()
161
186
 
162
187
  val lazyListState = options.scrollData.scrollState as? LazyListState
@@ -165,11 +190,16 @@ internal fun StackScreen(
165
190
  else
166
191
  (options.scrollData.scrollState as? ScrollState ?: rememberScrollState()).proxyLimitedScrollState(limit, 1)
167
192
  val snapState: ScrollableState = lazyListState ?: scrollState
193
+ val headerHide = rememberQuickReturnHide(
194
+ enabled = options.usesDuoQuickReturnHeader(screenLayout),
195
+ scrollState = scrollState,
196
+ heightPx = with(density) { screenLayout.headerHeight.toPx() },
197
+ )
168
198
 
169
199
  val footerHeightPx = remember { mutableIntStateOf(0) }
170
200
  val headerRightWidthPx = remember { mutableIntStateOf(0) }
171
201
 
172
- if (navigation.options.scrollData.scrollToTopCallback != null) {
202
+ if (tabFocused && navigation.options.scrollData.scrollToTopCallback != null) {
173
203
  RegisterScrollToTop(navigation.options.scrollData.scrollToTopCallback)
174
204
  }
175
205
 
@@ -189,40 +219,74 @@ internal fun StackScreen(
189
219
  LocalFooterHeightPx provides footerHeightPx,
190
220
  LocalHeaderRightWidthPx provides headerRightWidthPx
191
221
  ) {
192
- Box(
193
- Modifier
194
- .fillMaxSize()
195
- .background(options.backgroundColor ?: AppTheme.current.colors.background.default)
196
- .conditional(options.keyboardOptions.keyboardShouldPersistTaps) {
197
- hideKeyboardOnTap()
222
+ // AI-GENERATED START: custom screen background, full window and outside the keyboard padding
223
+ Box(Modifier.fillMaxSize()) {
224
+ options.backgroundComponent?.let { background ->
225
+ Box(Modifier.fillMaxSize()) { background() }
226
+ }
227
+ // AI-GENERATED END: custom screen background, full window and outside the keyboard padding
228
+ Box(
229
+ Modifier
230
+ .fillMaxSize()
231
+ // AI-GENERATED START: custom background replaces the colour
232
+ .background(
233
+ if (options.backgroundComponent != null) Color.Transparent
234
+ else options.backgroundColor ?: AppTheme.current.colors.background.default
235
+ )
236
+ // AI-GENERATED END: custom background replaces the colour
237
+ .conditional(options.keyboardOptions.keyboardShouldPersistTaps) {
238
+ hideKeyboardOnTap()
239
+ }
240
+ .conditional(options.keyboardOptions.useAvoidKeyboard && supportsImePadding()) {
241
+ imePadding()
242
+ }
243
+ ) {
244
+ Box(Modifier.zIndex(1f)) {
245
+ HeaderBackground()
198
246
  }
199
- .conditional(options.keyboardOptions.useAvoidKeyboard && supportsImePadding()) {
200
- imePadding()
247
+
248
+ Box(Modifier.zIndex(4f).graphicsLayer { translationY = -headerHide.value }) {
249
+ Header(onBackHandler)
201
250
  }
202
- ) {
203
- Box(Modifier.zIndex(1f)) {
204
- HeaderBackground()
205
- }
206
251
 
207
- Box(Modifier.zIndex(4f)) {
208
- Header(onBackHandler)
209
- }
252
+ HeaderSideBar(
253
+ bottomInset = maxOf(
254
+ bottomBarsHeight(isBottomTabChild) + Spacing.XL,
255
+ if (isBottomTabChild) LocalDuoRailReserve.current else 0.dp,
256
+ ),
257
+ modifier = Modifier.zIndex(4f),
258
+ )
210
259
 
211
- Column(Modifier.zIndex(5f)) {
212
- SearchAnimated(snapState = snapState)
213
- }
260
+ Column(Modifier.zIndex(5f).horizontalSafeAreaPadding()) {
261
+ SearchAnimated(snapState = snapState)
262
+ }
214
263
 
215
- Column(Modifier.zIndex(2f).fillMaxSize()) {
216
- MainContent(content = content, isBottomTabRoot = isBottomTabRoot, isBottomTabChild = isBottomTabChild)
217
- FooterContent()
218
- }
264
+ Column(Modifier.zIndex(2f).fillMaxSize()) {
265
+ MainContent(content = content, isBottomTabRoot = isBottomTabRoot, isBottomTabChild = isBottomTabChild)
266
+ FooterContent()
267
+ }
219
268
 
220
- Box(Modifier.zIndex(6f)) {
221
- FloatingContent(isBottomTabChild = isBottomTabChild)
222
- }
269
+ Box(Modifier.zIndex(6f).horizontalSafeAreaPadding()) {
270
+ FloatingContent(isBottomTabChild = isBottomTabChild)
271
+ }
223
272
 
224
- OverplayView(isBottomTabChild = isBottomTabChild, id = id)
273
+ OverplayView(isBottomTabChild = isBottomTabChild, id = id)
274
+ }
275
+ // AI-GENERATED START: custom screen background, full window and outside the keyboard padding
225
276
  }
277
+ // AI-GENERATED END: custom screen background, full window and outside the keyboard padding
278
+ }
279
+ }
280
+
281
+ // Height the footer, tab bar or home indicator takes from the bottom of the screen.
282
+ @Composable
283
+ private fun bottomBarsHeight(isBottomTabChild: Boolean): Dp {
284
+ val options = LocalOptions.current
285
+ val navigationBar = AppNavigationBar.current
286
+ return when {
287
+ options.hasFooter() -> with(LocalDensity.current) { LocalFooterHeightPx.current.intValue.toDp() }
288
+ isBottomTabChild -> bottomTabChildInset()
289
+ else -> navigationBar
226
290
  }
227
291
  }
228
292
 
@@ -230,18 +294,10 @@ internal fun StackScreen(
230
294
  @Composable
231
295
  internal fun FloatingContent(isBottomTabChild: Boolean) {
232
296
  val options = LocalOptions.current
233
- val density = LocalDensity.current
234
- val footerHeightPx = LocalFooterHeightPx.current
235
297
  val scrollState = LocalScrollState.current
236
- val navigationBar = AppNavigationBar.current
237
298
 
238
299
  val fabProps = options.floatingButtonProps ?: return
239
- val bottomPadding = fabProps.bottom
240
- ?: (Spacing.M + when {
241
- options.hasFooter() -> with(density) { footerHeightPx.intValue.toDp() }
242
- isBottomTabChild -> bottomTabBarInset() + (navigationBar - keyboardSizeState().value).coerceAtLeast(0.dp)
243
- else -> navigationBar
244
- })
300
+ val bottomPadding = fabProps.bottom ?: (Spacing.M + bottomBarsHeight(isBottomTabChild))
245
301
 
246
302
  FloatingButton(
247
303
  scrollPosition = fabProps.scrollState?.value ?: scrollState.value,
@@ -263,11 +319,13 @@ internal fun ColumnScope.MainContent(content: @Composable () -> Unit, isBottomTa
263
319
  val density = LocalDensity.current
264
320
  val scrollState = LocalScrollState.current
265
321
  val navigationBar = AppNavigationBar.current
322
+ val screenLayout = getScreenLayout()
323
+ val useDuoGlass = options.usesDuoGlassHeader(screenLayout)
266
324
 
267
325
  Spacer(
268
326
  Modifier.height(
269
- if (options.headerType is HeaderType.DefaultOrExtended || (options.headerType is HeaderType.Transparent && !options.headerType.isFullScreenContent))
270
- AppStatusBar.current + HEADER_HEIGHT.dp else 0.dp
327
+ if (!useDuoGlass && (options.headerType is HeaderType.DefaultOrExtended || (options.headerType is HeaderType.Transparent && !options.headerType.isFullScreenContent)))
328
+ screenLayout.headerHeight else 0.dp
271
329
  )
272
330
  )
273
331
  if (inputSearchType == InputSearchType.Animated) {
@@ -275,31 +333,46 @@ internal fun ColumnScope.MainContent(content: @Composable () -> Unit, isBottomTa
275
333
 
276
334
  Spacer(Modifier.height((HEADER_HEIGHT.dp - scrollDp).coerceIn(0.dp, HEADER_HEIGHT.dp)))
277
335
  }
336
+ // a bottom tab root stays full width: the tab bar and each tab's own StackScreen handle the insets
337
+ val insetContent = !isBottomTabRoot
278
338
  Column(
279
339
  Modifier
280
340
  .fillMaxWidth()
281
341
  .weight(1f)
342
+ .conditional(insetContent && options.headerType !is HeaderType.Animated) {
343
+ horizontalSafeAreaPadding()
344
+ }
282
345
  .conditional(options.scrollData.scrollable && options.scrollData.scrollState is ScrollState) {
283
346
  verticalScroll(scrollState)
284
347
  }
348
+ .conditional(useDuoGlass) {
349
+ padding(top = screenLayout.headerHeight)
350
+ }
285
351
  .padding(
286
352
  bottom = when {
287
353
  options.hasFooter() || isBottomTabRoot -> 0.dp
288
- isBottomTabChild -> bottomTabBarInset() + (navigationBar - keyboardSizeState().value).coerceAtLeast(0.dp)
354
+ isBottomTabChild -> bottomTabChildInset()
289
355
  !options.scrollData.scrollable && options.scrollData.scrollState == null -> 0.dp
290
356
  else -> navigationBar
291
357
  }
292
358
  )
293
359
  ) {
294
- ScreenContent(content = content)
360
+ ScreenContent(content = content, insetContent = insetContent)
295
361
  }
296
362
 
297
363
  }
298
364
 
299
365
  @Composable
300
- internal fun ScreenContent(content: @Composable () -> Unit) {
366
+ internal fun ScreenContent(content: @Composable () -> Unit, insetContent: Boolean) {
301
367
  val scrollState = LocalScrollState.current
302
368
  val options = LocalOptions.current
369
+ val screenLayout = getScreenLayout()
370
+ val insetBody: @Composable () -> Unit = {
371
+ CompositionLocalProvider(
372
+ LocalScreenLayout provides if (insetContent) screenLayout.consumeHorizontalInsets() else screenLayout,
373
+ content = content,
374
+ )
375
+ }
303
376
 
304
377
  if (options.headerType is HeaderType.Animated) {
305
378
  val animatedHeader = options.headerType
@@ -310,12 +383,16 @@ internal fun ScreenContent(content: @Composable () -> Unit) {
310
383
  Box(bannerModifier) {
311
384
  animatedHeader.composable.invoke(scrollState.value)
312
385
  }
313
- Box(Modifier.padding(top = AppStatusBar.current + HEADER_HEIGHT.dp + animatedHeader.layoutOffSet)) {
314
- content()
386
+ Box(
387
+ Modifier
388
+ .padding(top = screenLayout.headerHeight + animatedHeader.layoutOffSet)
389
+ .conditional(insetContent) { horizontalSafeAreaPadding() }
390
+ ) {
391
+ insetBody()
315
392
  }
316
393
  }
317
394
  } else {
318
- content()
395
+ insetBody()
319
396
  }
320
397
  }
321
398
 
@@ -324,7 +401,9 @@ internal fun FooterContent() {
324
401
  val options = LocalOptions.current
325
402
  if (options.hasFooter()) {
326
403
  val keyboardSize = keyboardSizeState()
327
- val bottomPadding = (AppNavigationBar.current - keyboardSize.value).coerceAtLeast(0.dp)
404
+ // the Duo floating tab bar overlays tab content, so a tab's footer sits above it
405
+ val duoTabInset = if (LocalNavigation.current.bottomTabIndex != -1) duoFloatingTabBarInset(getScreenLayout()) else 0.dp
406
+ val bottomPadding = (maxOf(AppNavigationBar.current, duoTabInset) - keyboardSize.value).coerceAtLeast(0.dp)
328
407
  Footer(
329
408
  footerComponent = options.footerComponent,
330
409
  bottomPadding = bottomPadding,
@@ -372,9 +451,10 @@ fun Footer(
372
451
 
373
452
  val backgroundColor = if (transparent) Color.Transparent else AppTheme.current.colors.background.surface
374
453
 
454
+ // inset as a whole, not padded inside, so the surface and its shadow stay off the iPhone Duo side bar
375
455
  Box(Modifier.onGloballyPositioned {
376
456
  if (footerHeightPx.intValue != it.size.height) footerHeightPx.intValue = it.size.height
377
- }) {
457
+ }.horizontalSafeAreaPadding()) {
378
458
  Box(
379
459
  Modifier
380
460
  .fillMaxWidth()
@@ -429,17 +509,28 @@ internal fun SearchAnimated(
429
509
  if (inputSearchType == InputSearchType.None) return
430
510
  val inputSearchProps = (options.headerType as? HeaderType.DefaultOrExtended)?.inputSearchProps ?: return
431
511
 
432
- val minTopPadding = AppStatusBar.current
433
- val maxTopPadding = AppStatusBar.current + HEADER_HEIGHT.dp
512
+ val screenLayout = getScreenLayout()
513
+ val useSideBar = screenLayout.sideBar != null
514
+ // collapsed, the search is centered in the bar row
515
+ val minTopPadding = screenLayout.headerStatusBarHeight + (screenLayout.headerBarHeight - HEADER_HEIGHT.dp) / 2
516
+ val maxTopPadding = screenLayout.headerHeight
517
+ // collapsed on the iPhone Duo status bar row, the search stays between its clusters (48pt back there)
518
+ val clearance = screenLayout.statusBarClearance
519
+ val backWidth = if (clearance != null && !useSideBar) DuoMetrics.controlSize else 28.dp
434
520
  val minStartPadding = Spacing.M
435
- val maxStartPadding = if (options.hiddenBack) Spacing.M else 48.dp // button back: size + left +right = 28 + 12 + 8 = 48
521
+ val maxStartPadding = (clearance?.left ?: 0.dp) +
522
+ if (options.hiddenBack || useSideBar) Spacing.M else backWidth + Spacing.M + Spacing.S
436
523
  val minEndPadding = Spacing.M
437
- val maxEndPadding = headerRightWidthDp + if (options.headerRight is HeaderRight.None) Spacing.M else Spacing.M * 2
524
+ val maxEndPadding = (clearance?.right ?: 0.dp) + when {
525
+ options.headerRight is HeaderRight.None -> Spacing.M
526
+ useSideBar && options.headerRight is HeaderRight.Toolkit -> Spacing.M
527
+ else -> headerRightWidthDp + Spacing.M * 2
528
+ }
438
529
 
439
530
  val targetColor = lerp(
440
531
  AppTheme.current.colors.background.surface,
441
532
  AppTheme.current.colors.background.default,
442
- (scrollDp / minTopPadding).coerceIn(0f, 1f)
533
+ (scrollDp / minTopPadding.coerceAtLeast(1.dp)).coerceIn(0f, 1f)
443
534
  )
444
535
  val animatedColor by animateColorAsState(targetValue = targetColor, label = "BackgroundColor")
445
536
 
@@ -581,6 +672,52 @@ internal val LocalOptions = staticCompositionLocalOf<NavigationOptions> { error(
581
672
 
582
673
  val StackScreenScrollableState = staticCompositionLocalOf<ScrollableState?> { null }
583
674
 
675
+ // iPhone Duo side bar: the header leaves with the content and slides back in on a reverse scroll
676
+ // (RN DuoQuickReturnHeader). The side bar is drawn apart from the header, so it stays put.
677
+ @Composable
678
+ private fun rememberQuickReturnHide(
679
+ enabled: Boolean,
680
+ scrollState: ScrollState,
681
+ heightPx: Float,
682
+ ): Animatable<Float, AnimationVector1D> {
683
+ val hide = remember { Animatable(0f) }
684
+ LaunchedEffect(enabled, scrollState, heightPx) {
685
+ if (!enabled) {
686
+ hide.snapTo(0f)
687
+ return@LaunchedEffect
688
+ }
689
+ var last = scrollState.value
690
+ snapshotFlow { scrollState.value }.collect { y ->
691
+ val delta = y - last
692
+ last = y
693
+ hide.snapTo(if (y <= 0) 0f else (hide.value + delta).coerceIn(0f, heightPx))
694
+ }
695
+ }
696
+ // a half-shown header settles fully in or out once the scroll stops
697
+ LaunchedEffect(enabled, scrollState, heightPx) {
698
+ if (!enabled) return@LaunchedEffect
699
+ snapshotFlow { scrollState.isScrollInProgress }
700
+ .filter { !it }
701
+ .collect {
702
+ val value = hide.value
703
+ // within the first header height the header still sits on the content
704
+ if (scrollState.value < heightPx || value <= 0f || value >= heightPx) return@collect
705
+ launch { hide.animateTo(if (value > heightPx / 2) heightPx else 0f, tween(200)) }
706
+ }
707
+ }
708
+ return hide
709
+ }
710
+
711
+ internal fun NavigationOptions.usesDuoQuickReturnHeader(layout: ScreenLayout): Boolean =
712
+ usesDuoGlassHeader(layout) || (layout.sideBar != null && headerType is HeaderType.Animated &&
713
+ scrollData.scrollable && scrollData.scrollState is ScrollState)
714
+
715
+ // iPhone Duo side bar: content scrolls under a glass header instead of starting below it (RN Screen).
716
+ internal fun NavigationOptions.usesDuoGlassHeader(layout: ScreenLayout): Boolean =
717
+ layout.sideBar != null && headerType is HeaderType.DefaultOrExtended &&
718
+ getInputSearchType(this) == InputSearchType.None &&
719
+ scrollData.scrollable && scrollData.scrollState is ScrollState
720
+
584
721
  internal fun getInputSearchType(options: NavigationOptions): InputSearchType {
585
722
  return when (val headerType = options.headerType) {
586
723
  is HeaderType.DefaultOrExtended -> when {