@momo-kits/native-kits 0.167.1-duo.21 → 0.167.1-duo.22-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 (149) hide show
  1. package/build.gradle.kts +11 -0
  2. package/compose/build.gradle.kts +204 -0
  3. package/compose/build.gradle.kts.backup +204 -0
  4. package/compose/compose.podspec +47 -0
  5. package/compose/src/androidMain/kotlin/vn/momo/kits/const/HostFont.android.kt +19 -0
  6. package/compose/src/androidMain/kotlin/vn/momo/kits/navigation/ScrollToTop.android.kt +6 -0
  7. package/compose/src/androidMain/kotlin/vn/momo/kits/platform/BackGesture.android.kt +12 -0
  8. package/compose/src/androidMain/kotlin/vn/momo/kits/platform/OsFontScale.android.kt +7 -0
  9. package/compose/src/androidMain/kotlin/vn/momo/kits/platform/Platform.android.kt +153 -0
  10. package/compose/src/androidMain/kotlin/vn/momo/kits/platform/ReservedRegions.android.kt +6 -0
  11. package/compose/src/commonMain/kotlin/vn/momo/kits/application/Context.kt +124 -0
  12. package/compose/src/commonMain/kotlin/vn/momo/kits/application/LiteScreen.kt +951 -0
  13. package/compose/src/commonMain/kotlin/vn/momo/kits/application/Localize.kt +269 -0
  14. package/compose/src/commonMain/kotlin/vn/momo/kits/application/WidgetContainer.kt +56 -0
  15. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Avatar.kt +157 -0
  16. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Badge.kt +81 -0
  17. package/compose/src/commonMain/kotlin/vn/momo/kits/components/BadgeDot.kt +32 -0
  18. package/compose/src/commonMain/kotlin/vn/momo/kits/components/BadgeRibbon.kt +338 -0
  19. package/compose/src/commonMain/kotlin/vn/momo/kits/components/BaselineView.kt +175 -0
  20. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Button.kt +357 -0
  21. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Carousel.kt +115 -0
  22. package/compose/src/commonMain/kotlin/vn/momo/kits/components/CheckBox.kt +89 -0
  23. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Chip.kt +128 -0
  24. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Collapse.kt +209 -0
  25. package/compose/src/commonMain/kotlin/vn/momo/kits/components/CupertinoOverscroll.kt +542 -0
  26. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Divider.kt +49 -0
  27. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Icon.kt +86 -0
  28. package/compose/src/commonMain/kotlin/vn/momo/kits/components/IconButton.kt +125 -0
  29. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Image.kt +231 -0
  30. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Information.kt +189 -0
  31. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Input.kt +478 -0
  32. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputDropDown.kt +114 -0
  33. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputMoney.kt +265 -0
  34. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputOTP.kt +235 -0
  35. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputPhoneNumber.kt +223 -0
  36. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputSearch.kt +265 -0
  37. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputTextArea.kt +185 -0
  38. package/compose/src/commonMain/kotlin/vn/momo/kits/components/LazyColumnWithBouncing.kt +343 -0
  39. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Loader.kt +108 -0
  40. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PaginationDot.kt +56 -0
  41. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PaginationNumber.kt +41 -0
  42. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PaginationScroll.kt +84 -0
  43. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PaginationWhiteDot.kt +40 -0
  44. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PopupNotify.kt +361 -0
  45. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PopupPromotion.kt +108 -0
  46. package/compose/src/commonMain/kotlin/vn/momo/kits/components/ProgressInfo.kt +308 -0
  47. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Radio.kt +59 -0
  48. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Rating.kt +87 -0
  49. package/compose/src/commonMain/kotlin/vn/momo/kits/components/ScaleSizeScope.kt +21 -0
  50. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Skeleton.kt +90 -0
  51. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Slider.kt +330 -0
  52. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Stepper.kt +220 -0
  53. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Steps.kt +473 -0
  54. package/compose/src/commonMain/kotlin/vn/momo/kits/components/SuggestAction.kt +122 -0
  55. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Swipe.kt +207 -0
  56. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Switch.kt +92 -0
  57. package/compose/src/commonMain/kotlin/vn/momo/kits/components/TabView.kt +560 -0
  58. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Tag.kt +86 -0
  59. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Text.kt +125 -0
  60. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Title.kt +207 -0
  61. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Tooltip.kt +501 -0
  62. package/compose/src/commonMain/kotlin/vn/momo/kits/components/TrustBanner.kt +176 -0
  63. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Uploader.kt +182 -0
  64. package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/DateTimePicker.kt +193 -0
  65. package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/DateTimePickerTypes.kt +29 -0
  66. package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/DateTimePickerUtils.kt +236 -0
  67. package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/WheelPicker.kt +180 -0
  68. package/compose/src/commonMain/kotlin/vn/momo/kits/const/Colors.kt +325 -0
  69. package/compose/src/commonMain/kotlin/vn/momo/kits/const/HostFont.kt +20 -0
  70. package/compose/src/commonMain/kotlin/vn/momo/kits/const/KitDesignSystem.kt +18 -0
  71. package/compose/src/commonMain/kotlin/vn/momo/kits/const/KitFontScale.kt +22 -0
  72. package/compose/src/commonMain/kotlin/vn/momo/kits/const/Radius.kt +12 -0
  73. package/compose/src/commonMain/kotlin/vn/momo/kits/const/Spacing.kt +16 -0
  74. package/compose/src/commonMain/kotlin/vn/momo/kits/const/Theme.kt +194 -0
  75. package/compose/src/commonMain/kotlin/vn/momo/kits/const/Typography.kt +283 -0
  76. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/Card.kt +54 -0
  77. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/Item.kt +50 -0
  78. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/ScreenLayout.kt +309 -0
  79. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/Section.kt +41 -0
  80. package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/AutomationId.kt +52 -0
  81. package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/Clickable.kt +66 -0
  82. package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/Conditional.kt +11 -0
  83. package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/DeprecatedModifier.kt +104 -0
  84. package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/Shadow.kt +48 -0
  85. package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/Size.kt +51 -0
  86. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/BottomSheet.kt +338 -0
  87. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/HostBackGestureBridge.kt +58 -0
  88. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/ModalScreen.kt +137 -0
  89. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigation.kt +116 -0
  90. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/NavigationContainer.kt +205 -0
  91. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigator.kt +399 -0
  92. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/ScrollToTop.kt +8 -0
  93. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/StackScreen.kt +727 -0
  94. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/StackTransition.kt +148 -0
  95. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/SwipeBackHaptic.kt +35 -0
  96. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTab.kt +218 -0
  97. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTabBar.kt +231 -0
  98. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/CurvedContainer.kt +86 -0
  99. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/DuoBarCompression.kt +98 -0
  100. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/DuoTabBar.kt +312 -0
  101. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/FloatingButton.kt +169 -0
  102. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/Header.kt +455 -0
  103. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderBackground.kt +103 -0
  104. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderJourney.kt +106 -0
  105. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderLocation.kt +74 -0
  106. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderRight.kt +399 -0
  107. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderTitle.kt +39 -0
  108. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderUser.kt +363 -0
  109. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/SideBar.kt +192 -0
  110. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/SnackBar.kt +133 -0
  111. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/tracking/ScreenTracker.kt +127 -0
  112. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/BackGesture.kt +12 -0
  113. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/ComposeLottieAnimation.kt +91 -0
  114. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/OsFontScale.kt +9 -0
  115. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/Platform.kt +74 -0
  116. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/ReservedRegions.kt +13 -0
  117. package/compose/src/commonMain/kotlin/vn/momo/kits/utils/Icons.kt +1329 -0
  118. package/compose/src/commonMain/kotlin/vn/momo/kits/utils/Resources.kt +55 -0
  119. package/compose/src/commonMain/kotlin/vn/momo/kits/utils/Tracking.kt +15 -0
  120. package/compose/src/commonMain/kotlin/vn/momo/kits/utils/Utils.kt +118 -0
  121. package/compose/src/iosMain/kotlin/vn/momo/kits/const/HostFont.ios.kt +78 -0
  122. package/compose/src/iosMain/kotlin/vn/momo/kits/navigation/HostBackGesture.ios.kt +45 -0
  123. package/compose/src/iosMain/kotlin/vn/momo/kits/navigation/ScrollToTop.ios.kt +32 -0
  124. package/compose/src/iosMain/kotlin/vn/momo/kits/platform/BackGesture.ios.kt +38 -0
  125. package/compose/src/iosMain/kotlin/vn/momo/kits/platform/OsFontScale.ios.kt +66 -0
  126. package/compose/src/iosMain/kotlin/vn/momo/kits/platform/Platform.ios.kt +217 -0
  127. package/compose/src/iosMain/kotlin/vn/momo/kits/platform/ReservedRegions.ios.kt +94 -0
  128. package/gradle/libs.versions.toml +69 -0
  129. package/gradle/wrapper/gradle-wrapper.jar +0 -0
  130. package/gradle/wrapper/gradle-wrapper.properties +8 -0
  131. package/gradle.properties +26 -0
  132. package/gradlew +252 -0
  133. package/gradlew.bat +94 -0
  134. package/ios/Application/MaxApi.swift +20 -2
  135. package/ios/Input/InputSearch.swift +5 -2
  136. package/ios/Navigation/BottomSheet.swift +58 -37
  137. package/ios/Navigation/BottomTab/BottomTab.swift +165 -18
  138. package/ios/Navigation/BottomTab/BottomTabBar.swift +3 -0
  139. package/ios/Navigation/Component/FloatingButton.swift +29 -51
  140. package/ios/Navigation/Component/Header.swift +19 -5
  141. package/ios/Navigation/Component/HeaderRight.swift +48 -19
  142. package/ios/Navigation/Component/SnackBar.swift +3 -2
  143. package/ios/Navigation/ModalScreen.swift +2 -1
  144. package/ios/Navigation/NavigationContainer.swift +3 -2
  145. package/ios/Navigation/Navigator.swift +30 -16
  146. package/ios/Navigation/StackScreen.swift +151 -37
  147. package/ios-demo/README.md +120 -0
  148. package/package.json +1 -1
  149. package/settings.gradle.kts +64 -0
@@ -36,6 +36,7 @@ public struct BottomSheetHeader {
36
36
  // MARK: - Bottom sheet view
37
37
 
38
38
  struct BottomSheetView<Content: View>: View {
39
+ let overplayId: UUID
39
40
  let header: BottomSheetHeader
40
41
  let isSurface: Bool
41
42
  let barrierDismissible: Bool
@@ -56,6 +57,7 @@ struct BottomSheetView<Content: View>: View {
56
57
  @State private var backgroundAlpha: CGFloat = 0
57
58
  @State private var sheetHeight: CGFloat = 0
58
59
  @State private var didAppear = false
60
+ @State private var isClosing = false
59
61
  @State private var keyboardHeight: CGFloat = 0
60
62
  /// Live drag offset, isolated from `sheetOffset` so a touch-move only invalidates
61
63
  /// this gesture-scoped value instead of forcing the whole body (including
@@ -95,7 +97,6 @@ struct BottomSheetView<Content: View>: View {
95
97
  isDuo && !isDuoCentered && layout.width > layout.height ? layout.insets.trailing : 0
96
98
  }
97
99
 
98
- /// The keyboard filler is part of the sheet, so capping the sheet shrinks the content by it.
99
100
  private var maxHeight: CGFloat {
100
101
  isDuo ? layout.height - max(layout.duoTopInset, Spacing.XL) : screenHeight - 90
101
102
  }
@@ -160,43 +161,47 @@ struct BottomSheetView<Content: View>: View {
160
161
 
161
162
  private var sheet: some View {
162
163
  VStack(spacing: 0) {
163
- // Drag handle + header (the draggable region).
164
164
  VStack(spacing: 0) {
165
- Spacer().frame(height: 8)
166
- Capsule()
167
- .fill(Colors.black06)
168
- .frame(width: 40, height: 4)
169
- Spacer().frame(height: 4)
170
- headerView
171
- .frame(height: 56)
172
- .padding(.horizontal, Spacing.M)
165
+ // Drag handle + header (the draggable region).
166
+ VStack(spacing: 0) {
167
+ Spacer().frame(height: 8)
168
+ Capsule()
169
+ .fill(Colors.black06)
170
+ .frame(width: 40, height: 4)
171
+ Spacer().frame(height: 4)
172
+ headerView
173
+ .frame(height: 56)
174
+ .padding(.horizontal, Spacing.M)
175
+ .padding(.leading, sideInsetLeading)
176
+ .padding(.trailing, sideInsetTrailing)
177
+ }
178
+ .frame(maxWidth: .infinity)
179
+ .frame(height: 72)
180
+ .contentShape(Rectangle())
181
+ .gesture(dragGesture, including: draggable ? .all : .subviews)
182
+
183
+ Rectangle()
184
+ .fill(theme.colors.border.default)
185
+ .frame(height: 1)
186
+
187
+ content()
173
188
  .padding(.leading, sideInsetLeading)
174
189
  .padding(.trailing, sideInsetTrailing)
190
+ // a tall sheet keeps its header (and footer) and gives the content what is left
191
+ .layoutPriority(-1)
192
+
193
+ if let footer {
194
+ BottomSheetFooter(content: footer)
195
+ }
175
196
  }
176
- .frame(maxWidth: .infinity)
177
- .frame(height: 72)
178
- .contentShape(Rectangle())
179
- .gesture(dragGesture, including: draggable ? .all : .subviews)
180
-
181
- Rectangle()
182
- .fill(theme.colors.border.default)
183
- .frame(height: 1)
184
-
185
- content()
186
- .padding(.leading, sideInsetLeading)
187
- .padding(.trailing, sideInsetTrailing)
188
- // a tall sheet keeps its header (and footer) and gives the content what is left
189
- .layoutPriority(-1)
190
-
191
- if let footer {
192
- BottomSheetFooter(content: footer)
193
- }
197
+ // The filler stays outside the cap, which shrinks by it instead: the sheet still ends at
198
+ // `maxHeight`, and the iOS 15 fallback never measures the keyboard as content.
199
+ .modifier(CapHeight(maxHeight: maxHeight - effectiveBottomInset))
194
200
 
195
201
  (footer != nil ? theme.colors.background.surface : backgroundColor)
196
202
  .frame(height: effectiveBottomInset)
197
203
  }
198
204
  .frame(maxWidth: isDuoCentered ? duoCenteredWidth : .infinity)
199
- .modifier(CapHeight(maxHeight: maxHeight))
200
205
  .clipped()
201
206
  .background(
202
207
  GeometryReader { geo in
@@ -241,14 +246,16 @@ struct BottomSheetView<Content: View>: View {
241
246
  // same bottom-anchored top edge — is still in flight doesn't fight a
242
247
  // moving target. Without this, the live drag value chases an animated
243
248
  // one, producing a couple of corrective jumps before it settles.
244
- if sheetOffset != 0 {
249
+ // A closing sheet keeps going down.
250
+ if sheetOffset != 0 && !isClosing {
245
251
  sheetOffset = 0
246
252
  }
247
253
  }
248
254
  .updating($dragTranslation) { value, state, _ in
249
- state = max(value.translation.height, 0)
255
+ state = isClosing ? 0 : max(value.translation.height, 0)
250
256
  }
251
257
  .onEnded { value in
258
+ guard !isClosing else { return }
252
259
  let finalOffset = max(value.translation.height, 0)
253
260
  // Capture into persisted state BEFORE @GestureState resets to 0,
254
261
  // avoiding a one-frame snap back to rest before close()'s own
@@ -276,6 +283,8 @@ struct BottomSheetView<Content: View>: View {
276
283
  /// Mirrors Compose's `closeEvent`: slide out over 200ms, then fade the scrim
277
284
  /// out over 100ms only after the slide finishes, then dismiss.
278
285
  private func close() {
286
+ guard !isClosing else { return }
287
+ isClosing = true
279
288
  withAnimation(.timingCurve(0.3, 0, 0.8, 0.15, duration: 0.2)) {
280
289
  sheetOffset = max(sheetHeight, screenHeight)
281
290
  }
@@ -285,7 +294,7 @@ struct BottomSheetView<Content: View>: View {
285
294
  }
286
295
  }
287
296
  DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
288
- navigator.finishOverplayDismiss()
297
+ navigator.finishOverplayDismiss(id: overplayId)
289
298
  }
290
299
  }
291
300
  }
@@ -306,10 +315,12 @@ private struct CapHeight: ViewModifier {
306
315
  }
307
316
 
308
317
  /// iOS 15 has no `Layout`: the sheet hugs its ideal height until that outgrows the cap, then is
309
- /// held at the cap (the ideal height can't be read again without a second copy of the content).
318
+ /// held at the cap. A capped sheet can't read its ideal height (that needs a second copy of the
319
+ /// content), so it is re-measured only when the cap grows, e.g. once the keyboard goes down.
310
320
  private struct CapHeightFallback: ViewModifier {
311
321
  let maxHeight: CGFloat
312
322
  @State private var capped = false
323
+ @State private var idealHeight: CGFloat = 0
313
324
 
314
325
  func body(content: Content) -> some View {
315
326
  content
@@ -318,15 +329,25 @@ private struct CapHeightFallback: ViewModifier {
318
329
  .background(
319
330
  GeometryReader { proxy in
320
331
  Color.clear
321
- .onAppear { cap(proxy.size.height) }
322
- .onChange(of: proxy.size.height) { cap($0) }
332
+ .onAppear { measure(proxy.size.height) }
333
+ .onChange(of: proxy.size.height) { measure($0) }
323
334
  }
324
335
  )
325
336
  .frame(height: capped ? maxHeight : nil, alignment: .top)
337
+ .onChange(of: maxHeight) { cap in
338
+ if capped {
339
+ // the ideal height was over the old cap, so only a taller cap can release it
340
+ if cap > idealHeight { capped = false }
341
+ } else if idealHeight > cap {
342
+ capped = true
343
+ }
344
+ }
326
345
  }
327
346
 
328
- private func cap(_ height: CGFloat) {
329
- if !capped, height > maxHeight { capped = true }
347
+ private func measure(_ height: CGFloat) {
348
+ guard !capped else { return }
349
+ idealHeight = height
350
+ if height > maxHeight { capped = true }
330
351
  }
331
352
  }
332
353
 
@@ -8,11 +8,14 @@
8
8
  //
9
9
 
10
10
  import SwiftUI
11
+ import UIKit
11
12
 
12
13
  public struct BottomTab: View {
13
14
  private let items: [BottomTabItem]
14
15
  private let floatingButton: BottomTabFloatingButton?
15
16
  private let compressionBehavior: DuoBarCompressionBehavior
17
+ private let onTabPress: ((_ index: Int, _ switchTab: @escaping () -> Void) -> Void)?
18
+ private let backToInitialTab: Bool
16
19
 
17
20
  @EnvironmentObject private var navigator: Navigator
18
21
  /// The screen hosting the tabs (Compose's `LocalNavigation` in BottomTab).
@@ -20,21 +23,33 @@ public struct BottomTab: View {
20
23
  @Environment(\.screenLayout) private var layout
21
24
  @Environment(\.appTheme) private var theme
22
25
  @StateObject private var tabNavigations = TabNavigations()
23
- @State private var selectedIndex: Int = 0
26
+ /// Compose's `startRoute`: picked once from `initialRouteName`.
27
+ @State private var startIndex: Int
28
+ @State private var selectedIndex: Int
24
29
  @State private var toolbarHeight = DuoBarCompression.defaultToolbarHeight
25
30
  @State private var expandedBar: DuoBar?
26
31
  @State private var openedBar: DuoBar?
27
32
 
28
33
  /// `compressionBehavior`: on iPhone Duo, when the side bar can't hold both bars, which one
29
34
  /// starts compressed; tapping the compressed one swaps them.
35
+ /// `onTabPress` gets every tab press with the switch to perform, e.g. after a login gate.
36
+ /// `backToInitialTab`: back off another tab returns to the initial one before leaving the screen.
30
37
  public init(
31
38
  items: [BottomTabItem],
32
39
  floatingButton: BottomTabFloatingButton? = nil,
33
- compressionBehavior: DuoBarCompressionBehavior = .toolbar
40
+ compressionBehavior: DuoBarCompressionBehavior = .toolbar,
41
+ initialRouteName: String? = nil,
42
+ onTabPress: ((_ index: Int, _ switchTab: @escaping () -> Void) -> Void)? = nil,
43
+ backToInitialTab: Bool = true
34
44
  ) {
35
45
  self.items = items
36
46
  self.floatingButton = floatingButton
37
47
  self.compressionBehavior = compressionBehavior
48
+ self.onTabPress = onTabPress
49
+ self.backToInitialTab = backToInitialTab
50
+ let start = items.firstIndex { $0.name == initialRouteName } ?? 0
51
+ _startIndex = State(initialValue: start)
52
+ _selectedIndex = State(initialValue: start)
38
53
  }
39
54
 
40
55
  private var bottomInset: CGFloat { safeAreaBottomInset() }
@@ -73,18 +88,12 @@ public struct BottomTab: View {
73
88
  let compression = compression
74
89
  ZStack(alignment: .bottom) {
75
90
  // Tab content area, inset above the bar.
76
- ZStack {
77
- ForEach(items.indices, id: \.self) { index in
78
- if index == selectedIndex {
79
- tabScreen(for: index)
80
- .transition(.scale(scale: 0.97).combined(with: .opacity))
81
- }
82
- }
83
- }
84
- .padding(.bottom, reservedBottom)
85
- .environment(\.duoTabBarInsets, duoInsets(compression))
86
- .environment(\.duoBarCompression, compression)
87
- .animation(.easeInOut(duration: 0.2), value: selectedIndex)
91
+ TabHost(selectedIndex: selectedIndex, count: items.count) { index in AnyView(tabScreen(for: index)) }
92
+ // each tab hosts itself, so it needs the window's safe area to draw its header under the status bar
93
+ .ignoresSafeArea(.container, edges: [.top, .horizontal])
94
+ .padding(.bottom, reservedBottom)
95
+ .environment(\.duoTabBarInsets, duoInsets(compression))
96
+ .environment(\.duoBarCompression, compression)
88
97
 
89
98
  if layout.isIPhoneDuo {
90
99
  DuoTabBar(
@@ -128,14 +137,15 @@ public struct BottomTab: View {
128
137
  }
129
138
 
130
139
  private func selectTab(_ index: Int) {
131
- if index != selectedIndex { selectedIndex = index }
140
+ let switchTab = { if index != selectedIndex { selectedIndex = index } }
141
+ if let onTabPress { onTabPress(index, switchTab) } else { switchTab() }
132
142
  }
133
143
 
134
- /// Back off the first tab returns to it; the host shows its floating arrow there (RN parity).
144
+ /// Back off the initial tab returns to it; the host shows its floating arrow there (RN parity).
135
145
  private func updateTabBack() {
136
- let isFirstTab = selectedIndex == 0
146
+ let isFirstTab = selectedIndex == startIndex || !backToInitialTab
137
147
  HostBackGestureBridge.shared.shouldUseFloatingArrowBack = !isFirstTab
138
- navigator.tabBackHandler = isFirstTab ? nil : (hostNavigation?.id ?? -1, { selectedIndex = 0 })
148
+ navigator.tabBackHandler = isFirstTab ? nil : (hostNavigation?.id ?? -1, { selectedIndex = startIndex })
139
149
  }
140
150
 
141
151
  private func tabScreen(for index: Int) -> some View {
@@ -176,6 +186,143 @@ private struct TabScreenHost: View {
176
186
  }
177
187
  }
178
188
 
189
+ // MARK: - Tab host
190
+
191
+ /// Compose's tab NavHost with `saveState` / `restoreState`. A tab gets its own UIHostingController
192
+ /// on first selection and keeps it, so its state and scroll survive a switch; while hidden it is
193
+ /// out of the window, so it gets onDisappear / onAppear (and re-runs screen tracking) like a NavHost
194
+ /// destination or a UITabBarController tab. Same scheme as `StackController`.
195
+ private struct TabHost: UIViewControllerRepresentable {
196
+ let selectedIndex: Int
197
+ let count: Int
198
+ let content: (Int) -> AnyView
199
+
200
+ func makeUIViewController(context: Context) -> TabController {
201
+ TabController()
202
+ }
203
+
204
+ func updateUIViewController(_ controller: TabController, context: Context) {
205
+ controller.update(selectedIndex: selectedIndex, count: count, environment: context.environment, content: content)
206
+ }
207
+ }
208
+
209
+ private final class TabController: UIViewController {
210
+ private final class Tab {
211
+ let motion = TabMotion()
212
+ var host: UIHostingController<AnyView>!
213
+ }
214
+
215
+ private static let switchDuration: TimeInterval = 0.2
216
+
217
+ private var tabs: [Int: Tab] = [:]
218
+ private var selectedIndex: Int?
219
+ private var environment = EnvironmentValues()
220
+ private var content: (Int) -> AnyView = { _ in AnyView(EmptyView()) }
221
+
222
+ override func viewDidLoad() {
223
+ super.viewDidLoad()
224
+ view.backgroundColor = .clear
225
+ }
226
+
227
+ override func viewDidLayoutSubviews() {
228
+ super.viewDidLayoutSubviews()
229
+ for subview in view.subviews { subview.frame = view.bounds }
230
+ }
231
+
232
+ func update(
233
+ selectedIndex index: Int,
234
+ count: Int,
235
+ environment: EnvironmentValues,
236
+ content: @escaping (Int) -> AnyView
237
+ ) {
238
+ self.environment = environment
239
+ self.content = content
240
+ for (stale, removed) in tabs where stale >= count {
241
+ tabs[stale] = nil
242
+ tearDown(removed)
243
+ }
244
+ if let selectedIndex, tabs[selectedIndex] == nil { self.selectedIndex = nil }
245
+ // hidden tabs too, so a tab coming back already shows the latest items and environment
246
+ for (tabIndex, existing) in tabs { existing.host.rootView = rootView(tabIndex, existing) }
247
+ guard (0..<count).contains(index), index != selectedIndex else { return }
248
+
249
+ let previous = selectedIndex.flatMap { tabs[$0] }
250
+ selectedIndex = index
251
+ let selected = tabs[index] ?? makeTab(index)
252
+ attach(selected)
253
+
254
+ guard let previous, view.window != nil else {
255
+ selected.motion.visible = true
256
+ previous.map(leave)
257
+ return
258
+ }
259
+ previous.host.view.isUserInteractionEnabled = false
260
+ // a frame at the start values first, so a tab created just now fades in too
261
+ DispatchQueue.main.async {
262
+ selected.motion.visible = true
263
+ previous.motion.visible = false
264
+ }
265
+ DispatchQueue.main.asyncAfter(deadline: .now() + Self.switchDuration) { [weak self] in
266
+ self?.leave(previous)
267
+ }
268
+ }
269
+
270
+ private func makeTab(_ index: Int) -> Tab {
271
+ let tab = Tab()
272
+ let host = UIHostingController(rootView: rootView(index, tab))
273
+ host.view.backgroundColor = .clear
274
+ addChild(host)
275
+ host.didMove(toParent: self)
276
+ tab.host = host
277
+ tabs[index] = tab
278
+ return tab
279
+ }
280
+
281
+ private func rootView(_ index: Int, _ tab: Tab) -> AnyView {
282
+ AnyView(TabCard(motion: tab.motion, content: content(index)).environment(\.self, environment))
283
+ }
284
+
285
+ private func attach(_ tab: Tab) {
286
+ let hostView: UIView = tab.host.view
287
+ hostView.isUserInteractionEnabled = true
288
+ hostView.frame = view.bounds
289
+ if hostView.superview === view {
290
+ view.bringSubviewToFront(hostView)
291
+ } else {
292
+ view.addSubview(hostView)
293
+ }
294
+ }
295
+
296
+ /// Off the window once hidden; a tab selected again in the meantime stays.
297
+ private func leave(_ tab: Tab) {
298
+ guard selectedIndex.flatMap({ tabs[$0] }) !== tab else { return }
299
+ tab.host.view.removeFromSuperview()
300
+ }
301
+
302
+ private func tearDown(_ tab: Tab) {
303
+ tab.host.willMove(toParent: nil)
304
+ tab.host.view.removeFromSuperview()
305
+ tab.host.removeFromParent()
306
+ }
307
+ }
308
+
309
+ private final class TabMotion: ObservableObject {
310
+ @Published var visible = false
311
+ }
312
+
313
+ /// The tab switch: 0.97 scale + fade over 200ms, both ways (Compose's tab enter / exit transitions).
314
+ private struct TabCard: View {
315
+ @ObservedObject var motion: TabMotion
316
+ let content: AnyView
317
+
318
+ var body: some View {
319
+ content
320
+ .opacity(motion.visible ? 1 : 0)
321
+ .scaleEffect(motion.visible ? 1 : 0.97)
322
+ .animation(.easeInOut(duration: 0.2), value: motion.visible)
323
+ }
324
+ }
325
+
179
326
  /// Bottom safe-area inset (home indicator).
180
327
  private func safeAreaBottomInset() -> CGFloat {
181
328
  let bottomInset = UIApplication.shared.connectedScenes
@@ -20,6 +20,7 @@ public struct BottomTabItem: Identifiable {
20
20
  public let showDot: Bool
21
21
  public let badgeLabel: String?
22
22
  public let options: NavigationOptions?
23
+ public let initialParams: Any?
23
24
  public let screen: () -> AnyView
24
25
 
25
26
  public init(
@@ -29,6 +30,7 @@ public struct BottomTabItem: Identifiable {
29
30
  showDot: Bool = false,
30
31
  badgeLabel: String? = nil,
31
32
  options: NavigationOptions? = nil,
33
+ initialParams: Any? = nil,
32
34
  @ViewBuilder screen: @escaping () -> some View
33
35
  ) {
34
36
  self.name = name
@@ -37,6 +39,7 @@ public struct BottomTabItem: Identifiable {
37
39
  self.showDot = showDot
38
40
  self.badgeLabel = badgeLabel
39
41
  self.options = options
42
+ self.initialParams = initialParams
40
43
  self.screen = { AnyView(screen()) }
41
44
  }
42
45
  }
@@ -7,7 +7,6 @@
7
7
  //
8
8
 
9
9
  import SwiftUI
10
- import Combine
11
10
 
12
11
  public enum FABSize {
13
12
  case small
@@ -66,14 +65,10 @@ public struct FabProps {
66
65
  }
67
66
 
68
67
  public struct FloatingButton: View {
68
+ @State private var isExpanded: Bool = true
69
69
  @State private var lastScrollOffset: CGFloat = 0
70
- @State private var isExpanded: Bool = false
71
- @State private var internalScrollOffset: CGFloat = 0
72
- @State private var showText: Bool = true
73
- @State private var fullWidth: CGFloat? = nil
74
- @State private var currentWidth: CGFloat = 0
75
- @State private var lastDirection: String? = nil
76
-
70
+ @State private var settleScroll: Task<Void, Never>?
71
+
77
72
  private let props: FabProps
78
73
  private var keyboardOffset: CGFloat
79
74
  private var scrollOffset: CGFloat
@@ -129,57 +124,40 @@ public struct FloatingButton: View {
129
124
  .onTapGesture {
130
125
  onClick?()
131
126
  }
127
+ .accessibilityElement(children: .combine)
128
+ .accessibilityAddTraits(.isButton)
132
129
  .padding(.bottom, bottomPadding)
133
130
  .frame(alignment: position == .center ? .center : .trailing)
134
- .onAppear {
135
- internalScrollOffset = scrollOffset
136
- isExpanded = label != nil
137
- }
138
- .onReceive(Just(scrollOffset)) { newValue in
139
- let direction = newValue > internalScrollOffset ? "down" : "up"
131
+ .onAppear { lastScrollOffset = scrollOffset }
132
+ .onChange(of: scrollOffset, perform: updateExpanded)
133
+ .onDisappear { settleScroll?.cancel() }
134
+ }
140
135
 
141
- if direction != lastDirection {
142
- lastDirection = direction
143
- if direction == "down" {
144
- withAnimation(.easeOut(duration: 0.1)) {
145
- isExpanded = false
146
- }
147
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
148
- showText = false
149
- }
150
- } else {
151
- showText = true
152
- withAnimation(.easeIn(duration: 0.1)) {
153
- isExpanded = true
154
- }
155
-
156
- }
157
- }
136
+ /// Compose's `collectLatest { isExpanded = new <= last; delay(100); last = new }`: the label
137
+ /// shows while the content scrolls back up, and the reference only moves once the scroll pauses.
138
+ private func updateExpanded(_ offset: CGFloat) {
139
+ let expanded = offset <= lastScrollOffset
140
+ if expanded != isExpanded {
141
+ withAnimation(.linear(duration: 0.1)) { isExpanded = expanded }
142
+ }
143
+ settleScroll?.cancel()
144
+ settleScroll = Task { @MainActor in
145
+ try? await Task.sleep(nanoseconds: 100_000_000)
146
+ guard !Task.isCancelled else { return }
147
+ lastScrollOffset = offset
158
148
  }
159
149
  }
160
-
150
+
161
151
  private var content: some View {
162
152
  HStack(spacing: 0) {
163
- ImageView(icon ?? "")
164
- .scaledToFit()
165
- .frame(width: iconSize, height: iconSize)
166
- .foregroundColor(iconColor)
167
-
168
- if let label = label {
169
- if showText {
170
- Spacer().frame(width: 8)
171
- MomoText(label, typography: .actionDefaultBold, color: .white, maxLines: 1)
172
- .background(
173
- GeometryReader { geo in
174
- Color.clear.onAppear {
175
- fullWidth = geo.size.width
176
- }
177
- }
178
- )
179
- .opacity(isExpanded ? 1 : 0)
180
- }
181
- }
153
+ // Compose `Icon`: an icon key or an https URL, tinted
154
+ Icon(source: icon ?? "", size: iconSize ?? defaultIconSize, color: iconColor)
182
155
 
156
+ if let label, isExpanded {
157
+ MomoText(label, typography: .actionDefaultBold, color: .white, maxLines: 1)
158
+ .padding(.leading, 12)
159
+ .transition(.opacity.combined(with: .move(edge: .leading)))
160
+ }
183
161
  }
184
162
  }
185
163
  }
@@ -273,11 +273,18 @@ public struct HeaderRightWidthKey: PreferenceKey {
273
273
  }
274
274
 
275
275
  /// Compose `SearchAnimated`: a header search fills from surface to the screen background over
276
- /// the scroll that lifts it into the bar.
277
- func headerSearchProps(_ props: InputSearchProps, scroll: CGFloat, layout: ScreenLayout, theme: Theme) -> InputSearchProps {
278
- guard props.backgroundColor == nil else { return props }
279
- let collapsedTop = max(layout.headerStatusBarHeight + (layout.headerBarHeight - HEADER_HEIGHT) / 2, 1)
276
+ /// the scroll that lifts it into the bar, and its button text pops the screen.
277
+ func headerSearchProps(
278
+ _ props: InputSearchProps,
279
+ scroll: CGFloat,
280
+ layout: ScreenLayout,
281
+ theme: Theme,
282
+ pop: (() -> Void)?
283
+ ) -> InputSearchProps {
280
284
  var themed = props
285
+ if !props.hasButtonTextAction, let pop { themed.onPressButtonText = pop }
286
+ guard props.backgroundColor == nil else { return themed }
287
+ let collapsedTop = max(layout.headerStatusBarHeight + (layout.headerBarHeight - HEADER_HEIGHT) / 2, 1)
281
288
  themed.backgroundColor = theme.colors.background.surface
282
289
  .lerp(to: theme.colors.background.default, max(scroll, 0) / collapsedTop)
283
290
  return themed
@@ -338,6 +345,7 @@ public struct NavigationHeader: View {
338
345
 
339
346
  @Environment(\.screenLayout) private var layout
340
347
  @Environment(\.appTheme) private var theme
348
+ @OptionalEnvironmentObject private var navigator: Navigator?
341
349
  @State private var headerRightWidth: CGFloat = 0
342
350
 
343
351
  /// iPhone Duo side bar holds back and the toolkit; a custom header right stays in the bar.
@@ -437,7 +445,13 @@ public struct NavigationHeader: View {
437
445
  @ViewBuilder
438
446
  private func titleArea(centered: Bool) -> some View {
439
447
  if let inputSearchProps {
440
- InputSearch(props: headerSearchProps(inputSearchProps, scroll: scrollState, layout: layout, theme: theme))
448
+ InputSearch(props: headerSearchProps(
449
+ inputSearchProps,
450
+ scroll: scrollState,
451
+ layout: layout,
452
+ theme: theme,
453
+ pop: navigator.map { navigator in { navigator.pop() } }
454
+ ))
441
455
  } else {
442
456
  switch headerTitle {
443
457
  case let .user(data)?: