@momo-kits/native-kits 0.167.1-duo.21-debug → 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.
@@ -40,7 +40,7 @@ kotlin {
40
40
  }
41
41
 
42
42
  cocoapods {
43
- version = "0.167.1-duo.21-debug"
43
+ version = "0.167.1-duo.22-debug"
44
44
  summary = "IOS Shared module"
45
45
  homepage = "https://momo.vn"
46
46
  ios.deploymentTarget = "15.0"
@@ -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
@@ -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)?:
@@ -191,38 +191,64 @@ struct Toolkit: View {
191
191
  return json["response"]
192
192
  }
193
193
 
194
+ private typealias MaxApiRequest = (MaxApi, [String: Any?], @escaping ([String: Any?]?) -> Void) -> Void
195
+
196
+ /// Through `composeApi` while the host still passes one, else `maxApi` (Compose `IMaxApi`).
197
+ /// Hosts may answer off the main thread, so the response is handed back on it.
198
+ private func request(
199
+ _ funcName: String,
200
+ _ params: [String: Any],
201
+ maxApi maxApiRequest: MaxApiRequest,
202
+ onResponse: @escaping (Any?) -> Void = { _ in }
203
+ ) {
204
+ if let composeApi = environment.composeApi {
205
+ _ = composeApi.request(funcName: funcName, params: params) { response in
206
+ let value = parseResponse(response)
207
+ DispatchQueue.main.async { onResponse(value) }
208
+ }
209
+ } else if let maxApi = environment.maxApi {
210
+ maxApiRequest(maxApi, params.mapValues { $0 as Any? }) { data in
211
+ let value = data?["response"] ?? nil
212
+ DispatchQueue.main.async { onResponse(value) }
213
+ }
214
+ }
215
+ }
216
+
194
217
  // MARK: Actions
195
218
 
196
219
  private func loadFavoriteState() {
197
- _ = environment.composeApi?.request(
198
- funcName: "isFavoriteApp",
199
- params: ["code": apiValue(context?.appCode)]
220
+ request(
221
+ "isFavoriteApp",
222
+ ["code": apiValue(context?.appCode)],
223
+ maxApi: { $0.isFavoriteApp($1, callback: $2) }
200
224
  ) { response in
201
- isFavorite = parseResponse(response) as? Bool == true
225
+ isFavorite = response as? Bool == true
202
226
  }
203
227
  }
204
228
 
205
229
  private func onPressShortcut() {
206
- _ = environment.composeApi?.request(
207
- funcName: "onToolAction",
208
- params: ["item": ["key": "onFavorite"], "context": contextMap]
230
+ request(
231
+ "onToolAction",
232
+ ["item": ["key": "onFavorite"], "context": contextMap],
233
+ maxApi: { $0.onToolAction($1, callback: $2) }
209
234
  ) { response in
210
- if let result = parseResponse(response) as? [String: Any], result["success"] as? Bool == true {
235
+ if let result = response as? [String: Any], result["success"] as? Bool == true {
211
236
  isFavorite.toggle()
212
237
  }
213
238
  }
214
239
  }
215
240
 
216
241
  private func onPressMore() {
217
- _ = environment.composeApi?.request(
218
- funcName: "showTools",
219
- params: [
242
+ request(
243
+ "showTools",
244
+ [
220
245
  "useSystemTools": headerRight.useSystemTools,
221
246
  "tools": headerRight.tools.map { $0.toMap() },
222
247
  "context": contextMap
223
- ]
248
+ ],
249
+ maxApi: { $0.showTools($1, callback: $2) }
224
250
  ) { response in
225
- if let tool = parseResponse(response) as? String {
251
+ if let tool = response as? String {
226
252
  headerRight.toolCallback?(tool)
227
253
  }
228
254
  }
@@ -230,23 +256,26 @@ struct Toolkit: View {
230
256
 
231
257
  private func onPressHelpCenter() {
232
258
  guard context != nil else { return }
233
- _ = environment.composeApi?.request(
234
- funcName: "showHelpCenter",
235
- params: [
259
+ request(
260
+ "showHelpCenter",
261
+ [
236
262
  "appId": apiValue(context?.appId),
237
263
  "code": apiValue(context?.appCode),
238
264
  "name": apiValue(context?.appName),
239
265
  "icon": apiValue(context?.appIcon),
240
266
  "description": apiValue(context?.description)
241
- ]
242
- ) { _ in }
267
+ ],
268
+ maxApi: { $0.showHelpCenter($1, callback: $2) }
269
+ )
243
270
  }
244
271
 
245
272
  private func onPressClose() {
246
273
  if headerRight.useCloseIcon {
247
274
  environment.requestDismiss()
275
+ } else if let composeApi = environment.composeApi {
276
+ _ = composeApi.request(funcName: "dismissAll", params: nil) { _ in }
248
277
  } else {
249
- _ = environment.composeApi?.request(funcName: "dismissAll", params: nil) { _ in }
278
+ environment.maxApi?.dismissAll(completion: nil)
250
279
  }
251
280
  }
252
281
 
@@ -50,6 +50,7 @@ public struct SnackBar {
50
50
  /// Self-animating container. Slides the content up on appear, schedules an
51
51
  /// auto-dismiss after `duration`, and animates out before removing the overplay.
52
52
  struct SnackBarView: View {
53
+ let overplayId: UUID
53
54
  let snackBar: SnackBar
54
55
  /// Distance from the window bottom to the top of the screen's footer, or the navigation bar
55
56
  /// inset without one (Compose's `footerHeight` offset).
@@ -121,7 +122,7 @@ struct SnackBarView: View {
121
122
  offsetY = max(contentHeight, 200) + bottomInset
122
123
  }
123
124
  DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
124
- navigator.finishOverplayDismiss()
125
+ navigator.finishOverplayDismiss(id: overplayId)
125
126
  }
126
127
  }
127
128
 
@@ -133,7 +134,7 @@ struct SnackBarView: View {
133
134
  guard let duration = snackBar.duration else { return }
134
135
  let workItem = DispatchWorkItem {
135
136
  // Only auto-dismiss if this snack bar is still the active overplay.
136
- if navigator.overplay?.type == .snackBar {
137
+ if navigator.overplay?.id == overplayId {
137
138
  navigator.hideSnackBar()
138
139
  }
139
140
  }
@@ -10,6 +10,7 @@
10
10
  import SwiftUI
11
11
 
12
12
  struct ModalScreen<Content: View>: View {
13
+ let overplayId: UUID
13
14
  let barrierDismissible: Bool
14
15
  let content: () -> Content
15
16
 
@@ -74,7 +75,7 @@ struct ModalScreen<Content: View>: View {
74
75
  contentOpacity = 0
75
76
  }
76
77
  DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
77
- navigator.finishOverplayDismiss()
78
+ navigator.finishOverplayDismiss(id: overplayId)
78
79
  }
79
80
  }
80
81
  }
@@ -168,12 +168,13 @@ private struct OverplayHost: View {
168
168
  // the Navigator before the item is removed (mirrors Compose's overplay).
169
169
  switch item.type {
170
170
  case .modal:
171
- ModalScreen(barrierDismissible: item.barrierDismissible) {
171
+ ModalScreen(overplayId: item.id, barrierDismissible: item.barrierDismissible) {
172
172
  item.content()
173
173
  }
174
174
  .id(item.id)
175
175
  case .bottomSheet:
176
176
  BottomSheetView(
177
+ overplayId: item.id,
177
178
  header: item.header ?? .title(),
178
179
  isSurface: item.isSurface,
179
180
  barrierDismissible: item.barrierDismissible,
@@ -186,7 +187,7 @@ private struct OverplayHost: View {
186
187
  .id(item.id)
187
188
  case .snackBar:
188
189
  if let snackBar = item.snackBar {
189
- SnackBarView(snackBar: snackBar, bottomInset: navigator.snackBarBottomInset())
190
+ SnackBarView(overplayId: item.id, snackBar: snackBar, bottomInset: navigator.snackBarBottomInset())
190
191
  .id(item.id)
191
192
  }
192
193
  }
@@ -238,20 +238,19 @@ public final class Navigator: ObservableObject {
238
238
  // Mirrors Kotlin: a snackbar doesn't block back-navigation, so popping
239
239
  // while one is showing pops the screen underneath and clears the
240
240
  // snackbar immediately, without playing its dismiss animation.
241
- overplayDismissHandler = nil
242
- overplay = nil
241
+ replaceOverplay(with: nil)
243
242
  guard popScreen() else {
244
243
  callback?()
245
244
  return
246
245
  }
247
246
  popStep(remaining: remaining - 1, callback: callback)
248
- } else if overplay != nil {
247
+ } else if let closing = overplay?.id {
249
248
  hideOverplay()
250
249
  DispatchQueue.main.asyncAfter(deadline: .now() + Self.overplayDismissDelay) { [weak self] in
251
250
  guard let self else { return }
252
251
  // The component reports back through `finishOverplayDismiss` once its exit
253
252
  // animation ends; clear it here if it never did, so the step is not wasted.
254
- if self.overplay != nil { self.finishOverplayDismiss() }
253
+ self.finishOverplayDismiss(id: closing)
255
254
  self.popStep(remaining: remaining - 1, callback: callback)
256
255
  }
257
256
  } else {
@@ -302,6 +301,11 @@ public final class Navigator: ObservableObject {
302
301
  }
303
302
 
304
303
  public func onBackSafe(callback: (() -> Void)? = nil) {
304
+ // An open modal or bottom sheet takes the back first (their BackHandler in Compose).
305
+ if let overplay, overplay.type != .snackBar {
306
+ pop(1, callback: callback)
307
+ return
308
+ }
305
309
  let latest = currentScreen()
306
310
  if let tabBackHandler, tabBackHandler.screenId == -1 || tabBackHandler.screenId == latest?.id {
307
311
  tabBackHandler.handler()
@@ -420,14 +424,13 @@ public final class Navigator: ObservableObject {
420
424
  barrierDismissible: Bool = true,
421
425
  onDismiss: (() -> Void)? = nil
422
426
  ) {
423
- overplayDismissHandler = nil
424
- overplay = OverplayItem(
427
+ replaceOverplay(with: OverplayItem(
425
428
  type: .modal,
426
429
  content: { AnyView(content()) },
427
430
  barrierDismissible: barrierDismissible,
428
431
  onDismiss: onDismiss,
429
432
  screenId: entries.last?.id
430
- )
433
+ ))
431
434
  }
432
435
 
433
436
  public func showBottomSheet<V: View>(
@@ -440,8 +443,7 @@ public final class Navigator: ObservableObject {
440
443
  useBottomInset: Bool = true,
441
444
  footer: (() -> AnyView)? = nil
442
445
  ) {
443
- overplayDismissHandler = nil
444
- overplay = OverplayItem(
446
+ replaceOverplay(with: OverplayItem(
445
447
  type: .bottomSheet,
446
448
  content: { AnyView(content()) },
447
449
  barrierDismissible: barrierDismissible,
@@ -452,20 +454,19 @@ public final class Navigator: ObservableObject {
452
454
  useBottomInset: useBottomInset,
453
455
  footer: footer,
454
456
  screenId: entries.last?.id
455
- )
457
+ ))
456
458
  }
457
459
 
458
460
  /// Mirrors Compose's `showSnackBar(snackBar:)`.
459
461
  public func showSnackBar(_ snackBar: SnackBar, onDismiss: (() -> Void)? = nil) {
460
- overplayDismissHandler = nil
461
- overplay = OverplayItem(
462
+ replaceOverplay(with: OverplayItem(
462
463
  type: .snackBar,
463
464
  content: { AnyView(EmptyView()) },
464
465
  barrierDismissible: false,
465
466
  onDismiss: onDismiss,
466
467
  snackBar: snackBar,
467
468
  screenId: entries.last?.id
468
- )
469
+ ))
469
470
  }
470
471
 
471
472
  // MARK: Overplay dismissal
@@ -480,10 +481,23 @@ public final class Navigator: ObservableObject {
480
481
  /// `hardClearAfterDismiss`). Called by a component once its exit animation
481
482
  /// has completed.
482
483
  public func finishOverplayDismiss() {
483
- let dismissCallback = overplay?.onDismiss
484
+ replaceOverplay(with: nil)
485
+ }
486
+
487
+ /// Same, but only while `id` is still the overplay: an exit that ends after a newer overplay
488
+ /// took its place must not take that one down.
489
+ public func finishOverplayDismiss(id: UUID) {
490
+ guard overplay?.id == id else { return }
491
+ replaceOverplay(with: nil)
492
+ }
493
+
494
+ /// A replaced overplay counts as dismissed (Compose disposes it), so its `onDismiss` fires once,
495
+ /// whether it was still closing or not.
496
+ private func replaceOverplay(with item: OverplayItem?) {
497
+ let replaced = overplay
484
498
  overplayDismissHandler = nil
485
- overplay = nil
486
- dismissCallback?()
499
+ overplay = item
500
+ replaced?.onDismiss?()
487
501
  }
488
502
 
489
503
  public func hideOverplay() {
@@ -116,7 +116,6 @@ public struct StackScreen<Content: View>: View {
116
116
  @State private var isBottomTabRoot: Bool = false
117
117
  @State private var headerHide: CGFloat = 0
118
118
  @State private var lastScrollOffset: CGFloat = 0
119
- @State private var headerSettle: Task<Void, Never>?
120
119
  @State private var footerHeight: CGFloat = 0
121
120
 
122
121
  @Environment(\.applicationEnvironment) private var appEnv
@@ -126,9 +125,7 @@ public struct StackScreen<Content: View>: View {
126
125
  @Environment(\.appTheme) private var theme
127
126
  @OptionalEnvironmentObject private var navigation: Navigation?
128
127
 
129
- // Auto-tracking state (Compose's `remember { ScreenTrackingState() }` + the once-computed action).
130
- @State private var trackingState = ScreenTrackingState()
131
- @State private var trackingAction: String = ScreenTracker.getLastScreenName() != nil ? "push" : "back"
128
+ @State private var tracking: Task<Void, Never>?
132
129
 
133
130
  /// Compose's `headerOpacity()`: 0 over a banner or transparent header, 1 once the bar is opaque.
134
131
  private var opacity: CGFloat {
@@ -189,25 +186,21 @@ public struct StackScreen<Content: View>: View {
189
186
  return
190
187
  }
191
188
  headerHide = min(max(headerHide + delta, 0), layout.headerHeight)
192
- settleHeaderWhenScrollStops()
193
- }
194
-
195
- /// A half-shown header settles fully in or out. SwiftUI before iOS 18 reports no scroll
196
- /// phase, so the scroll counts as ended once the offset stops changing.
197
- private func settleHeaderWhenScrollStops() {
198
- headerSettle?.cancel()
199
- headerSettle = Task { @MainActor in
200
- try? await Task.sleep(nanoseconds: 150_000_000)
201
- guard !Task.isCancelled else { return }
202
- let height = layout.headerHeight
203
- // within the first header height the header still sits on the content
204
- guard scrollOffset >= height, headerHide > 0, headerHide < height else { return }
205
- withAnimation(.easeOut(duration: 0.2)) {
206
- headerHide = headerHide > height / 2 ? height : 0
207
- }
189
+ }
190
+
191
+ /// A half-shown header settles fully in or out once the scroll comes to rest.
192
+ private func settleHeader() {
193
+ let height = layout.headerHeight
194
+ // within the first header height the header still sits on the content
195
+ guard useQuickReturn, scrollOffset >= height, headerHide > 0, headerHide < height else { return }
196
+ withAnimation(.easeOut(duration: 0.2)) {
197
+ headerHide = headerHide > height / 2 ? height : 0
208
198
  }
209
199
  }
210
200
 
201
+ /// Compose snaps the animated search fully in or out when the scroll stops.
202
+ private var searchSnapDistance: CGFloat? { inputSearchType == .animated ? HEADER_HEIGHT : nil }
203
+
211
204
  private var keyboardSize: CGFloat { useAvoidKeyboard ? max(keyboardHeight, 0) : 0 }
212
205
 
213
206
  /// What the footer, tab bar or home indicator takes from the bottom of the window, gap included.
@@ -338,35 +331,50 @@ public struct StackScreen<Content: View>: View {
338
331
  }
339
332
  .onAppear(perform: reportBottomObstruction)
340
333
  .onChange(of: bottomObstruction) { _ in reportBottomObstruction() }
341
- .task(id: screenName) {
334
+ .onAppear(perform: startTracking)
335
+ .onChange(of: screenName) { _ in startTracking() }
336
+ .onDisappear { tracking?.cancel() }
337
+ }
338
+
339
+ /// Compose tracks from a `LaunchedEffect` with `remember`ed state, so every visit — first show, a
340
+ /// pop back, a tab switch — starts over with fresh state. A covered hosting controller keeps its
341
+ /// `@State` and doesn't re-run `.task`, so the run hangs off appear / disappear instead.
342
+ private func startTracking() {
343
+ tracking?.cancel()
344
+ let state = ScreenTrackingState()
345
+ let action = ScreenTracker.getLastScreenName() != nil ? "push" : "back"
346
+ let maxApi = appEnv.maxApi
347
+ let context = appEnv.applicationContext
348
+ let screenName = screenName
349
+ tracking = Task { @MainActor in
342
350
  ScreenTracker.trackScreenNavigated(
343
- maxApi: appEnv.maxApi,
344
- context: appEnv.applicationContext,
351
+ maxApi: maxApi,
352
+ context: context,
345
353
  screenName: screenName,
346
- action: trackingAction,
347
- state: trackingState
354
+ action: action,
355
+ state: state
348
356
  )
349
357
  do {
350
358
  try await Task.sleep(nanoseconds: 2_000_000_000)
351
359
  } catch {
352
360
  return
353
361
  }
354
- let loadTime = ScreenTracker.now() - trackingState.time
362
+ let loadTime = ScreenTracker.now() - state.time
355
363
  ScreenTracker.trackScreenDisplayed(
356
- maxApi: appEnv.maxApi,
357
- context: appEnv.applicationContext,
364
+ maxApi: maxApi,
365
+ context: context,
358
366
  screenName: screenName,
359
367
  duration: loadTime,
360
- state: trackingState
368
+ state: state
361
369
  )
362
- let interactionTime = ScreenTracker.now() - trackingState.time
370
+ let interactionTime = ScreenTracker.now() - state.time
363
371
  ScreenTracker.trackScreenInteracted(
364
- maxApi: appEnv.maxApi,
365
- context: appEnv.applicationContext,
372
+ maxApi: maxApi,
373
+ context: context,
366
374
  screenName: screenName,
367
375
  duration: interactionTime - loadTime,
368
376
  totalDuration: interactionTime,
369
- state: trackingState
377
+ state: state
370
378
  )
371
379
  }
372
380
  }
@@ -385,6 +393,8 @@ public struct StackScreen<Content: View>: View {
385
393
  scrollToTopCallback: scrollToTopCallback,
386
394
  animatedSearchInset: animatedSearchInset,
387
395
  bottomInset: duoTabBarInsets.overlay,
396
+ snapDistance: searchSnapDistance,
397
+ onScrollIdle: settleHeader,
388
398
  content: { animatedHeroBody(animatedHeader) }
389
399
  )
390
400
  .edgesIgnoringSafeArea(.all)
@@ -419,6 +429,8 @@ public struct StackScreen<Content: View>: View {
419
429
  scrollToTopCallback: scrollToTopCallback,
420
430
  animatedSearchInset: animatedSearchInset,
421
431
  bottomInset: duoTabBarInsets.overlay,
432
+ snapDistance: searchSnapDistance,
433
+ onScrollIdle: settleHeader,
422
434
  content: { duoInsetContent }
423
435
  )
424
436
  .edgesIgnoringSafeArea(.all)
@@ -438,6 +450,8 @@ public struct StackScreen<Content: View>: View {
438
450
  scrollToTopCallback: scrollToTopCallback,
439
451
  topInset: layout.headerHeight - layout.insets.top,
440
452
  bottomInset: duoTabBarInsets.overlay,
453
+ snapDistance: searchSnapDistance,
454
+ onScrollIdle: settleHeader,
441
455
  content: content
442
456
  )
443
457
 
@@ -483,6 +497,8 @@ public struct StackScreen<Content: View>: View {
483
497
  scrollToTopCallback: scrollToTopCallback,
484
498
  animatedSearchInset: animatedSearchInset,
485
499
  bottomInset: duoTabBarInsets.overlay,
500
+ snapDistance: searchSnapDistance,
501
+ onScrollIdle: settleHeader,
486
502
  content: content
487
503
  )
488
504
  }
@@ -563,11 +579,30 @@ private struct StackScreenContent<Content: View>: View {
563
579
  var topInset: CGFloat = 0
564
580
  /// Room for a bar that overlays the content (iPhone Duo floating tab bar).
565
581
  var bottomInset: CGFloat = 0
582
+ /// Compose's animated-search snap: a scroll that comes to rest part way through this distance
583
+ /// finishes the move, up or back down.
584
+ var snapDistance: CGFloat? = nil
585
+ var onScrollIdle: (() -> Void)? = nil
566
586
  let content: () -> Content
567
587
 
568
588
  private let topAnchorID = "stack_screen_top_anchor"
569
589
  private let scrollSpace = "stack_screen_scroll_space"
570
590
 
591
+ @State private var scrollView = WeakScrollView()
592
+
593
+ private func scrollDidRest() {
594
+ onScrollIdle?()
595
+ guard let snapDistance, scrollOffset > 0, scrollOffset < snapDistance,
596
+ let scrollView = scrollView.value else { return }
597
+ // UIKit, not `ScrollViewProxy.scrollTo`: SwiftUI keeps re-applying a scrollTo target while the
598
+ // content above it resizes, which this content does as the search inset shrinks with the scroll
599
+ let target = scrollOffset < snapDistance / 2 ? 0 : snapDistance
600
+ scrollView.setContentOffset(
601
+ CGPoint(x: scrollView.contentOffset.x, y: target - scrollView.adjustedContentInset.top),
602
+ animated: true
603
+ )
604
+ }
605
+
571
606
  var body: some View {
572
607
  if scrollable {
573
608
  ScrollViewReader { proxy in
@@ -587,6 +622,7 @@ private struct StackScreenContent<Content: View>: View {
587
622
  scrollOffset = -minY
588
623
  }
589
624
  })
625
+ .background(ScrollViewProbe { scrollView.value = $0 })
590
626
 
591
627
  if animatedSearchInset + topInset > 0 {
592
628
  Spacer().frame(height: animatedSearchInset + topInset)
@@ -602,6 +638,7 @@ private struct StackScreenContent<Content: View>: View {
602
638
  }
603
639
  }
604
640
  .coordinateSpace(name: scrollSpace)
641
+ .modifier(ScrollRest(offset: scrollOffset, onRest: scrollDidRest))
605
642
  // Status-bar tap (MoMoStatusBarTap swizzle) scrolls to top and notifies the consumer.
606
643
  .onReceive(NotificationCenter.default.publisher(for: MoMoStatusBarTap.notificationName)) { _ in
607
644
  withAnimation {
@@ -618,6 +655,74 @@ private struct StackScreenContent<Content: View>: View {
618
655
  }
619
656
  }
620
657
 
658
+ private final class WeakScrollView {
659
+ weak var value: UIScrollView?
660
+ }
661
+
662
+ /// Finds the UIScrollView behind the enclosing SwiftUI `ScrollView` once placed in its content.
663
+ private struct ScrollViewProbe: UIViewRepresentable {
664
+ let found: (UIScrollView) -> Void
665
+
666
+ func makeUIView(context: Context) -> Probe { Probe(found: found) }
667
+
668
+ func updateUIView(_ uiView: Probe, context: Context) {}
669
+
670
+ final class Probe: UIView {
671
+ let found: (UIScrollView) -> Void
672
+
673
+ init(found: @escaping (UIScrollView) -> Void) {
674
+ self.found = found
675
+ super.init(frame: .zero)
676
+ isUserInteractionEnabled = false
677
+ }
678
+
679
+ required init?(coder: NSCoder) { nil }
680
+
681
+ override func didMoveToWindow() {
682
+ super.didMoveToWindow()
683
+ var view = superview
684
+ while let current = view, !(current is UIScrollView) { view = current.superview }
685
+ if let scrollView = view as? UIScrollView { found(scrollView) }
686
+ }
687
+ }
688
+ }
689
+
690
+ /// Calls `onRest` once the scroll comes to rest. Scroll phases need iOS 18; before that the scroll
691
+ /// counts as ended once the offset stops changing, which a finger held still also looks like.
692
+ private struct ScrollRest: ViewModifier {
693
+ let offset: CGFloat
694
+ let onRest: () -> Void
695
+
696
+ @State private var pending: Task<Void, Never>?
697
+
698
+ func body(content: Content) -> some View {
699
+ #if compiler(>=6.0)
700
+ if #available(iOS 18.0, *) {
701
+ content.onScrollPhaseChange { _, phase in
702
+ if phase == .idle { onRest() }
703
+ }
704
+ } else {
705
+ debounced(content)
706
+ }
707
+ #else
708
+ debounced(content)
709
+ #endif
710
+ }
711
+
712
+ private func debounced(_ content: Content) -> some View {
713
+ content
714
+ .onChange(of: offset) { _ in
715
+ pending?.cancel()
716
+ pending = Task { @MainActor in
717
+ try? await Task.sleep(nanoseconds: 150_000_000)
718
+ guard !Task.isCancelled else { return }
719
+ onRest()
720
+ }
721
+ }
722
+ .onDisappear { pending?.cancel() }
723
+ }
724
+ }
725
+
621
726
  // MARK: - Animated search header
622
727
 
623
728
  /// Compose's `SearchAnimated`: the search slides from below the header (scroll 0) up into the bar
@@ -633,6 +738,7 @@ private struct SearchAnimatedHeader: View {
633
738
 
634
739
  @Environment(\.screenLayout) private var layout
635
740
  @Environment(\.appTheme) private var theme
741
+ @OptionalEnvironmentObject private var navigator: Navigator?
636
742
 
637
743
  var body: some View {
638
744
  let scroll = max(scrollOffset, 0)
@@ -650,12 +756,20 @@ private struct SearchAnimatedHeader: View {
650
756
  ? Spacing.M
651
757
  : headerRightWidth + (hasHeaderRight ? Spacing.M * 2 : Spacing.M))
652
758
 
653
- let top = min(max(maxTop - scroll, minTop), maxTop)
654
- let start = min(max(minSide + scroll, minSide), maxStart)
655
- let end = min(max(minSide + scroll, minSide), maxEnd)
759
+ // one progress for all three, so the field docks with its final insets and never covers the toolkit
760
+ let progress = min(scroll / HEADER_HEIGHT, 1)
761
+ let top = maxTop + (minTop - maxTop) * progress
762
+ let start = minSide + (maxStart - minSide) * progress
763
+ let end = minSide + (maxEnd - minSide) * progress
656
764
 
657
765
  // padding, not a Spacer: on the iPhone Duo status bar row the collapsed top is above the safe area
658
- InputSearch(props: headerSearchProps(props, scroll: scroll, layout: layout, theme: theme))
766
+ InputSearch(props: headerSearchProps(
767
+ props,
768
+ scroll: scroll,
769
+ layout: layout,
770
+ theme: theme,
771
+ pop: navigator.map { navigator in { navigator.pop() } }
772
+ ))
659
773
  .frame(height: HEADER_HEIGHT)
660
774
  .padding(.leading, start)
661
775
  .padding(.trailing, end)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@momo-kits/native-kits",
3
- "version": "0.167.1-duo.21-debug",
3
+ "version": "0.167.1-duo.22-debug",
4
4
  "private": false,
5
5
  "dependeNavigationncies": {},
6
6
  "devDependencies": {},