@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.
- package/compose/build.gradle.kts +1 -1
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigator.kt +6 -0
- package/ios/Application/MaxApi.swift +20 -2
- package/ios/Input/InputSearch.swift +5 -2
- package/ios/Navigation/BottomSheet.swift +58 -37
- package/ios/Navigation/BottomTab/BottomTab.swift +165 -18
- package/ios/Navigation/BottomTab/BottomTabBar.swift +3 -0
- package/ios/Navigation/Component/FloatingButton.swift +29 -51
- package/ios/Navigation/Component/Header.swift +19 -5
- package/ios/Navigation/Component/HeaderRight.swift +48 -19
- package/ios/Navigation/Component/SnackBar.swift +3 -2
- package/ios/Navigation/ModalScreen.swift +2 -1
- package/ios/Navigation/NavigationContainer.swift +3 -2
- package/ios/Navigation/Navigator.swift +30 -16
- package/ios/Navigation/StackScreen.swift +151 -37
- package/package.json +1 -1
package/compose/build.gradle.kts
CHANGED
|
@@ -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
|
|
9
|
-
//
|
|
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:
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
.
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
.frame(height:
|
|
172
|
-
|
|
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
|
-
|
|
177
|
-
.
|
|
178
|
-
.
|
|
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
|
-
|
|
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
|
|
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 {
|
|
322
|
-
.onChange(of: proxy.size.height) {
|
|
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
|
|
329
|
-
|
|
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
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
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 ==
|
|
146
|
+
let isFirstTab = selectedIndex == startIndex || !backToInitialTab
|
|
137
147
|
HostBackGestureBridge.shared.shouldUseFloatingArrowBack = !isFirstTab
|
|
138
|
-
navigator.tabBackHandler = isFirstTab ? nil : (hostNavigation?.id ?? -1, { selectedIndex =
|
|
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
|
|
71
|
-
|
|
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
|
-
|
|
136
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
164
|
-
|
|
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(
|
|
278
|
-
|
|
279
|
-
|
|
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(
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
220
|
+
request(
|
|
221
|
+
"isFavoriteApp",
|
|
222
|
+
["code": apiValue(context?.appCode)],
|
|
223
|
+
maxApi: { $0.isFavoriteApp($1, callback: $2) }
|
|
200
224
|
) { response in
|
|
201
|
-
isFavorite =
|
|
225
|
+
isFavorite = response as? Bool == true
|
|
202
226
|
}
|
|
203
227
|
}
|
|
204
228
|
|
|
205
229
|
private func onPressShortcut() {
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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 =
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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 =
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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?.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
486
|
-
|
|
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
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
.
|
|
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:
|
|
344
|
-
context:
|
|
351
|
+
maxApi: maxApi,
|
|
352
|
+
context: context,
|
|
345
353
|
screenName: screenName,
|
|
346
|
-
action:
|
|
347
|
-
state:
|
|
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() -
|
|
362
|
+
let loadTime = ScreenTracker.now() - state.time
|
|
355
363
|
ScreenTracker.trackScreenDisplayed(
|
|
356
|
-
maxApi:
|
|
357
|
-
context:
|
|
364
|
+
maxApi: maxApi,
|
|
365
|
+
context: context,
|
|
358
366
|
screenName: screenName,
|
|
359
367
|
duration: loadTime,
|
|
360
|
-
state:
|
|
368
|
+
state: state
|
|
361
369
|
)
|
|
362
|
-
let interactionTime = ScreenTracker.now() -
|
|
370
|
+
let interactionTime = ScreenTracker.now() - state.time
|
|
363
371
|
ScreenTracker.trackScreenInteracted(
|
|
364
|
-
maxApi:
|
|
365
|
-
context:
|
|
372
|
+
maxApi: maxApi,
|
|
373
|
+
context: context,
|
|
366
374
|
screenName: screenName,
|
|
367
375
|
duration: interactionTime - loadTime,
|
|
368
376
|
totalDuration: interactionTime,
|
|
369
|
-
state:
|
|
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
|
-
|
|
654
|
-
let
|
|
655
|
-
let
|
|
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(
|
|
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)
|