@momo-kits/native-kits 0.167.1-duo.21-debug → 0.167.1-duo.22

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/ios/Application/MaxApi.swift +20 -2
  2. package/ios/Input/InputSearch.swift +5 -2
  3. package/ios/Navigation/BottomSheet.swift +58 -37
  4. package/ios/Navigation/BottomTab/BottomTab.swift +165 -18
  5. package/ios/Navigation/BottomTab/BottomTabBar.swift +3 -0
  6. package/ios/Navigation/Component/FloatingButton.swift +29 -51
  7. package/ios/Navigation/Component/Header.swift +19 -5
  8. package/ios/Navigation/Component/HeaderRight.swift +48 -19
  9. package/ios/Navigation/Component/SnackBar.swift +3 -2
  10. package/ios/Navigation/ModalScreen.swift +2 -1
  11. package/ios/Navigation/NavigationContainer.swift +3 -2
  12. package/ios/Navigation/Navigator.swift +30 -16
  13. package/ios/Navigation/StackScreen.swift +151 -37
  14. package/package.json +1 -1
  15. package/build.gradle.kts +0 -11
  16. package/compose/build.gradle.kts +0 -204
  17. package/compose/build.gradle.kts.backup +0 -204
  18. package/compose/compose.podspec +0 -47
  19. package/compose/src/androidMain/kotlin/vn/momo/kits/const/HostFont.android.kt +0 -19
  20. package/compose/src/androidMain/kotlin/vn/momo/kits/navigation/ScrollToTop.android.kt +0 -6
  21. package/compose/src/androidMain/kotlin/vn/momo/kits/platform/BackGesture.android.kt +0 -12
  22. package/compose/src/androidMain/kotlin/vn/momo/kits/platform/OsFontScale.android.kt +0 -7
  23. package/compose/src/androidMain/kotlin/vn/momo/kits/platform/Platform.android.kt +0 -153
  24. package/compose/src/androidMain/kotlin/vn/momo/kits/platform/ReservedRegions.android.kt +0 -6
  25. package/compose/src/commonMain/kotlin/vn/momo/kits/application/Context.kt +0 -124
  26. package/compose/src/commonMain/kotlin/vn/momo/kits/application/LiteScreen.kt +0 -951
  27. package/compose/src/commonMain/kotlin/vn/momo/kits/application/Localize.kt +0 -269
  28. package/compose/src/commonMain/kotlin/vn/momo/kits/application/WidgetContainer.kt +0 -56
  29. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Avatar.kt +0 -157
  30. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Badge.kt +0 -81
  31. package/compose/src/commonMain/kotlin/vn/momo/kits/components/BadgeDot.kt +0 -32
  32. package/compose/src/commonMain/kotlin/vn/momo/kits/components/BadgeRibbon.kt +0 -338
  33. package/compose/src/commonMain/kotlin/vn/momo/kits/components/BaselineView.kt +0 -175
  34. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Button.kt +0 -357
  35. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Carousel.kt +0 -115
  36. package/compose/src/commonMain/kotlin/vn/momo/kits/components/CheckBox.kt +0 -89
  37. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Chip.kt +0 -128
  38. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Collapse.kt +0 -209
  39. package/compose/src/commonMain/kotlin/vn/momo/kits/components/CupertinoOverscroll.kt +0 -542
  40. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Divider.kt +0 -49
  41. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Icon.kt +0 -86
  42. package/compose/src/commonMain/kotlin/vn/momo/kits/components/IconButton.kt +0 -125
  43. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Image.kt +0 -231
  44. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Information.kt +0 -189
  45. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Input.kt +0 -478
  46. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputDropDown.kt +0 -114
  47. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputMoney.kt +0 -265
  48. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputOTP.kt +0 -235
  49. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputPhoneNumber.kt +0 -223
  50. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputSearch.kt +0 -265
  51. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputTextArea.kt +0 -185
  52. package/compose/src/commonMain/kotlin/vn/momo/kits/components/LazyColumnWithBouncing.kt +0 -343
  53. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Loader.kt +0 -108
  54. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PaginationDot.kt +0 -56
  55. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PaginationNumber.kt +0 -41
  56. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PaginationScroll.kt +0 -84
  57. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PaginationWhiteDot.kt +0 -40
  58. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PopupNotify.kt +0 -361
  59. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PopupPromotion.kt +0 -108
  60. package/compose/src/commonMain/kotlin/vn/momo/kits/components/ProgressInfo.kt +0 -308
  61. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Radio.kt +0 -59
  62. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Rating.kt +0 -87
  63. package/compose/src/commonMain/kotlin/vn/momo/kits/components/ScaleSizeScope.kt +0 -21
  64. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Skeleton.kt +0 -90
  65. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Slider.kt +0 -330
  66. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Stepper.kt +0 -220
  67. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Steps.kt +0 -473
  68. package/compose/src/commonMain/kotlin/vn/momo/kits/components/SuggestAction.kt +0 -122
  69. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Swipe.kt +0 -207
  70. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Switch.kt +0 -92
  71. package/compose/src/commonMain/kotlin/vn/momo/kits/components/TabView.kt +0 -560
  72. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Tag.kt +0 -86
  73. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Text.kt +0 -125
  74. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Title.kt +0 -207
  75. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Tooltip.kt +0 -501
  76. package/compose/src/commonMain/kotlin/vn/momo/kits/components/TrustBanner.kt +0 -176
  77. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Uploader.kt +0 -182
  78. package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/DateTimePicker.kt +0 -193
  79. package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/DateTimePickerTypes.kt +0 -29
  80. package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/DateTimePickerUtils.kt +0 -236
  81. package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/WheelPicker.kt +0 -180
  82. package/compose/src/commonMain/kotlin/vn/momo/kits/const/Colors.kt +0 -325
  83. package/compose/src/commonMain/kotlin/vn/momo/kits/const/HostFont.kt +0 -20
  84. package/compose/src/commonMain/kotlin/vn/momo/kits/const/KitDesignSystem.kt +0 -18
  85. package/compose/src/commonMain/kotlin/vn/momo/kits/const/KitFontScale.kt +0 -22
  86. package/compose/src/commonMain/kotlin/vn/momo/kits/const/Radius.kt +0 -12
  87. package/compose/src/commonMain/kotlin/vn/momo/kits/const/Spacing.kt +0 -16
  88. package/compose/src/commonMain/kotlin/vn/momo/kits/const/Theme.kt +0 -194
  89. package/compose/src/commonMain/kotlin/vn/momo/kits/const/Typography.kt +0 -283
  90. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/Card.kt +0 -54
  91. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/Item.kt +0 -50
  92. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/ScreenLayout.kt +0 -309
  93. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/Section.kt +0 -41
  94. package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/AutomationId.kt +0 -52
  95. package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/Clickable.kt +0 -66
  96. package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/Conditional.kt +0 -11
  97. package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/DeprecatedModifier.kt +0 -104
  98. package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/Shadow.kt +0 -48
  99. package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/Size.kt +0 -51
  100. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/BottomSheet.kt +0 -338
  101. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/HostBackGestureBridge.kt +0 -58
  102. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/ModalScreen.kt +0 -137
  103. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigation.kt +0 -116
  104. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/NavigationContainer.kt +0 -205
  105. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigator.kt +0 -393
  106. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/ScrollToTop.kt +0 -8
  107. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/StackScreen.kt +0 -727
  108. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/StackTransition.kt +0 -148
  109. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/SwipeBackHaptic.kt +0 -35
  110. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTab.kt +0 -218
  111. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTabBar.kt +0 -231
  112. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/CurvedContainer.kt +0 -86
  113. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/DuoBarCompression.kt +0 -98
  114. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/DuoTabBar.kt +0 -312
  115. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/FloatingButton.kt +0 -169
  116. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/Header.kt +0 -455
  117. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderBackground.kt +0 -103
  118. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderJourney.kt +0 -106
  119. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderLocation.kt +0 -74
  120. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderRight.kt +0 -399
  121. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderTitle.kt +0 -39
  122. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderUser.kt +0 -363
  123. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/SideBar.kt +0 -192
  124. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/SnackBar.kt +0 -133
  125. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/tracking/ScreenTracker.kt +0 -127
  126. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/BackGesture.kt +0 -12
  127. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/ComposeLottieAnimation.kt +0 -91
  128. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/OsFontScale.kt +0 -9
  129. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/Platform.kt +0 -74
  130. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/ReservedRegions.kt +0 -13
  131. package/compose/src/commonMain/kotlin/vn/momo/kits/utils/Icons.kt +0 -1329
  132. package/compose/src/commonMain/kotlin/vn/momo/kits/utils/Resources.kt +0 -55
  133. package/compose/src/commonMain/kotlin/vn/momo/kits/utils/Tracking.kt +0 -15
  134. package/compose/src/commonMain/kotlin/vn/momo/kits/utils/Utils.kt +0 -118
  135. package/compose/src/iosMain/kotlin/vn/momo/kits/const/HostFont.ios.kt +0 -78
  136. package/compose/src/iosMain/kotlin/vn/momo/kits/navigation/HostBackGesture.ios.kt +0 -45
  137. package/compose/src/iosMain/kotlin/vn/momo/kits/navigation/ScrollToTop.ios.kt +0 -32
  138. package/compose/src/iosMain/kotlin/vn/momo/kits/platform/BackGesture.ios.kt +0 -38
  139. package/compose/src/iosMain/kotlin/vn/momo/kits/platform/OsFontScale.ios.kt +0 -66
  140. package/compose/src/iosMain/kotlin/vn/momo/kits/platform/Platform.ios.kt +0 -217
  141. package/compose/src/iosMain/kotlin/vn/momo/kits/platform/ReservedRegions.ios.kt +0 -94
  142. package/gradle/libs.versions.toml +0 -69
  143. package/gradle/wrapper/gradle-wrapper.jar +0 -0
  144. package/gradle/wrapper/gradle-wrapper.properties +0 -8
  145. package/gradle.properties +0 -26
  146. package/gradlew +0 -252
  147. package/gradlew.bat +0 -94
  148. package/ios-demo/README.md +0 -120
  149. package/settings.gradle.kts +0 -64
@@ -5,8 +5,8 @@
5
5
  // Swift mirror of the host-app bridge exposed on Kotlin as `vn.momo.maxapi.IMaxApi`
6
6
  // (`LocalMaxApi`). `IMaxApi` itself is an external compiled dependency with no
7
7
  // source in this repo, so this protocol intentionally exposes only the methods
8
- // this kit's Kotlin source actually calls (`track`, `dismiss`, `getLanguage`,
9
- // `triggerEventVibration`) — not a full guess at IMaxApi's real surface.
8
+ // this kit's Kotlin source actually calls — not a full guess at IMaxApi's real surface.
9
+ // Callbacks carry IMaxApi's `["response": …]` payload.
10
10
  //
11
11
 
12
12
  import UIKit
@@ -17,6 +17,13 @@ public protocol MaxApi {
17
17
  func getLanguage(callback: @escaping ([String: Any?]?) -> Void)
18
18
  /// `type` is the IMaxApi vibration name (`"light"`, …).
19
19
  func triggerEventVibration(_ type: String)
20
+ // Toolkit (Compose `HeaderRight.Toolkit`).
21
+ func isFavoriteApp(_ params: [String: Any?], callback: @escaping ([String: Any?]?) -> Void)
22
+ func onToolAction(_ params: [String: Any?], callback: @escaping ([String: Any?]?) -> Void)
23
+ func showTools(_ params: [String: Any?], callback: @escaping ([String: Any?]?) -> Void)
24
+ func showHelpCenter(_ params: [String: Any?], callback: @escaping ([String: Any?]?) -> Void)
25
+ /// Back to the host home, closing every mini app.
26
+ func dismissAll(completion: (() -> Void)?)
20
27
  }
21
28
 
22
29
  public extension MaxApi {
@@ -24,4 +31,15 @@ public extension MaxApi {
24
31
  func triggerEventVibration(_ type: String) {
25
32
  UIImpactFeedbackGenerator(style: .light).impactOccurred()
26
33
  }
34
+
35
+ // Hosts that don't bridge the toolkit keep it inert, as before these joined the protocol.
36
+ func isFavoriteApp(_ params: [String: Any?], callback: @escaping ([String: Any?]?) -> Void) {}
37
+ func onToolAction(_ params: [String: Any?], callback: @escaping ([String: Any?]?) -> Void) {}
38
+ func showTools(_ params: [String: Any?], callback: @escaping ([String: Any?]?) -> Void) {}
39
+ func showHelpCenter(_ params: [String: Any?], callback: @escaping ([String: Any?]?) -> Void) {}
40
+
41
+ /// Without a home to return to, closing this mini app is the closest thing.
42
+ func dismissAll(completion: (() -> Void)?) {
43
+ dismiss(completion: completion)
44
+ }
27
45
  }
@@ -45,6 +45,8 @@ public struct InputSearchProps {
45
45
  var placeholder: String = ""
46
46
  var onChangeText: (String) -> Void = { _ in }
47
47
  var onPressButtonText: () -> Void = {}
48
+ /// Without one, a header search's button text pops the screen (Compose `SearchAnimated`).
49
+ var hasButtonTextAction = false
48
50
  var error: String = ""
49
51
  var disabled: Bool = false
50
52
  var icon: String = ""
@@ -70,7 +72,7 @@ public struct InputSearchProps {
70
72
  showBorder: Bool = true,
71
73
  placeholder: String = "",
72
74
  onChangeText: @escaping (String) -> Void = { _ in },
73
- onPressButtonText: @escaping () -> Void = {},
75
+ onPressButtonText: (() -> Void)? = nil,
74
76
  error: String = "",
75
77
  disabled: Bool = false,
76
78
  icon: String = "",
@@ -93,7 +95,8 @@ public struct InputSearchProps {
93
95
  self.showBorder = showBorder
94
96
  self.placeholder = placeholder
95
97
  self.onChangeText = onChangeText
96
- self.onPressButtonText = onPressButtonText
98
+ self.onPressButtonText = onPressButtonText ?? {}
99
+ self.hasButtonTextAction = onPressButtonText != nil
97
100
  self.error = error
98
101
  self.disabled = disabled
99
102
  self.icon = icon
@@ -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)?: