@momo-kits/native-kits 0.163.1-sp.5-debug → 0.163.1-sp.6-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/.claude/settings.local.json +12 -1
- package/compose/build.gradle.kts +1 -1
- package/compose/compose.podspec +1 -1
- package/gradle.properties +1 -1
- package/ios/Application/Navigation/NavigationContainer.swift +3 -0
- package/ios/Application/Navigation/Navigator.swift +85 -14
- package/ios/Application/Navigation/SwipeBackGesture.swift +116 -0
- package/ios/native-kits.podspec +1 -1
- package/ios-demo/MoMoUIKitsDemo.podspec +1 -1
- package/package.json +1 -1
|
@@ -128,7 +128,18 @@
|
|
|
128
128
|
"Bash(xcodebuild -workspace iosApp.xcworkspace -scheme iosApp -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' -configuration Debug build)",
|
|
129
129
|
"Bash(xcodebuild -workspace iosApp.xcworkspace -scheme iosApp -sdk iphonesimulator -destination 'platform=iOS Simulator,id=E6A899C0-098E-473A-95B8-A2BBD2D42A92' -configuration Debug build)",
|
|
130
130
|
"Bash(./gradlew :sample:shared:compileCommonMainKotlinMetadata -q)",
|
|
131
|
-
"Bash(echo \"gradle=$?\")"
|
|
131
|
+
"Bash(echo \"gradle=$?\")",
|
|
132
|
+
"Bash(xcodebuild -workspace iosApp.xcworkspace -scheme iosApp -sdk iphonesimulator -destination 'id=0228A4B2-57E0-4E56-BFAD-DB9E95044AD9' -derivedDataPath build/dd build)",
|
|
133
|
+
"Bash(osascript *)",
|
|
134
|
+
"Bash(screencapture -x -R253,53,452,950 win.png)",
|
|
135
|
+
"Bash(sips -g pixelWidth -g pixelHeight win.png)",
|
|
136
|
+
"Bash(screencapture -x -R\"253,53,452,950\" win.png)",
|
|
137
|
+
"Bash(screencapture -x full.png)",
|
|
138
|
+
"Bash(sips -g pixelWidth -g pixelHeight full.png)",
|
|
139
|
+
"Bash(sips -c 1900 904 --cropOffset 106 506 full.png --out crop.png)",
|
|
140
|
+
"Bash(swiftc -O mouse.swift -o mouse)",
|
|
141
|
+
"Bash(./mouse click *)",
|
|
142
|
+
"Bash(./mouse drag *)"
|
|
132
143
|
]
|
|
133
144
|
}
|
|
134
145
|
}
|
package/compose/build.gradle.kts
CHANGED
package/compose/compose.podspec
CHANGED
package/gradle.properties
CHANGED
|
@@ -109,6 +109,9 @@ public struct NavigationContainer<Initial: View>: View {
|
|
|
109
109
|
if let screen = navigator.registry.getScreen(id: route.id) {
|
|
110
110
|
ScreenHost(screen: screen, navigator: navigator)
|
|
111
111
|
.navigationBarBackButtonHidden(true)
|
|
112
|
+
// Hiding the back button is what kills the edge swipe; this puts it back
|
|
113
|
+
// and sends it through `onBackSafe`, like Compose's BackHandler.
|
|
114
|
+
.momoSwipeBack(navigator)
|
|
112
115
|
} else {
|
|
113
116
|
EmptyView()
|
|
114
117
|
}
|
|
@@ -89,7 +89,11 @@ public struct OverplayItem: Identifiable {
|
|
|
89
89
|
|
|
90
90
|
@available(iOS 16.0, *)
|
|
91
91
|
public final class Navigator: ObservableObject {
|
|
92
|
-
@Published public var path: [DynamicScreenRoute] = []
|
|
92
|
+
@Published public var path: [DynamicScreenRoute] = [] {
|
|
93
|
+
// The edge-swipe pop is driven by UIKit and lands straight on the binding,
|
|
94
|
+
// never on `pop`, so screen disposal hangs off the path itself.
|
|
95
|
+
didSet { disposeScreens(droppedFrom: oldValue) }
|
|
96
|
+
}
|
|
93
97
|
@Published public var presented: DynamicDialogRoute?
|
|
94
98
|
@Published public var overplay: OverplayItem?
|
|
95
99
|
|
|
@@ -147,8 +151,7 @@ public final class Navigator: ObservableObject {
|
|
|
147
151
|
|
|
148
152
|
public func replace<V: View>(@ViewBuilder _ content: @escaping () -> V, options: NavigationOptions? = nil) {
|
|
149
153
|
guard !path.isEmpty else { return }
|
|
150
|
-
|
|
151
|
-
registry.unregister(id: removed.id)
|
|
154
|
+
path.removeLast()
|
|
152
155
|
push(content, options: options)
|
|
153
156
|
}
|
|
154
157
|
|
|
@@ -158,14 +161,12 @@ public final class Navigator: ObservableObject {
|
|
|
158
161
|
options: NavigationOptions? = nil
|
|
159
162
|
) {
|
|
160
163
|
guard !path.isEmpty else { return }
|
|
161
|
-
|
|
162
|
-
registry.unregister(id: removed.id)
|
|
164
|
+
path.removeLast()
|
|
163
165
|
push(screenName: screenName, content, options: options)
|
|
164
166
|
}
|
|
165
167
|
|
|
166
168
|
public func reset<V: View>(@ViewBuilder _ content: @escaping () -> V, options: NavigationOptions? = nil) {
|
|
167
|
-
|
|
168
|
-
registry.unregister(id: rootRoute.id)
|
|
169
|
+
let previousRoot = rootRoute.id
|
|
169
170
|
path.removeAll()
|
|
170
171
|
let route = registry.register(
|
|
171
172
|
screenName: options?.screenName ?? "",
|
|
@@ -173,6 +174,7 @@ public final class Navigator: ObservableObject {
|
|
|
173
174
|
options: options
|
|
174
175
|
)
|
|
175
176
|
rootRoute = route
|
|
177
|
+
disposeScreens([previousRoot])
|
|
176
178
|
}
|
|
177
179
|
|
|
178
180
|
public func reset<V: View>(
|
|
@@ -180,8 +182,7 @@ public final class Navigator: ObservableObject {
|
|
|
180
182
|
@ViewBuilder _ content: @escaping () -> V,
|
|
181
183
|
options: NavigationOptions? = nil
|
|
182
184
|
) {
|
|
183
|
-
|
|
184
|
-
registry.unregister(id: rootRoute.id)
|
|
185
|
+
let previousRoot = rootRoute.id
|
|
185
186
|
path.removeAll()
|
|
186
187
|
let route = registry.register(
|
|
187
188
|
screenName: screenName,
|
|
@@ -189,6 +190,7 @@ public final class Navigator: ObservableObject {
|
|
|
189
190
|
options: options
|
|
190
191
|
)
|
|
191
192
|
rootRoute = route
|
|
193
|
+
disposeScreens([previousRoot])
|
|
192
194
|
}
|
|
193
195
|
|
|
194
196
|
public func pop(_ count: Int = 1, callback: (() -> Void)? = nil) {
|
|
@@ -218,11 +220,10 @@ public final class Navigator: ObservableObject {
|
|
|
218
220
|
if presented != nil {
|
|
219
221
|
let dismissedId = presented?.id
|
|
220
222
|
presented = nil
|
|
221
|
-
if let id = dismissedId {
|
|
223
|
+
if let id = dismissedId { disposeScreens([id]) }
|
|
222
224
|
return true
|
|
223
225
|
} else if !path.isEmpty {
|
|
224
|
-
|
|
225
|
-
registry.unregister(id: removed.id)
|
|
226
|
+
path.removeLast()
|
|
226
227
|
return true
|
|
227
228
|
} else {
|
|
228
229
|
composeApi?.requestCallback(funcName: "dismiss", params: nil, onResponse: nil)
|
|
@@ -231,12 +232,54 @@ public final class Navigator: ObservableObject {
|
|
|
231
232
|
}
|
|
232
233
|
|
|
233
234
|
public func popToRoot() {
|
|
234
|
-
for route in path { registry.unregister(id: route.id) }
|
|
235
235
|
path.removeAll()
|
|
236
236
|
}
|
|
237
237
|
|
|
238
|
+
/// The entry a back action applies to — mirrors Compose's `currentScreen()`
|
|
239
|
+
/// (`navController.currentBackStackEntry`): a presented screen sits on top of
|
|
240
|
+
/// the stack, otherwise it is the last pushed screen, otherwise the root.
|
|
241
|
+
private func currentScreen() -> DynamicScreen? {
|
|
242
|
+
registry.getScreen(id: presented?.id ?? path.last?.id ?? rootRoute.id)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/// Whether the current screen takes back navigation over itself, i.e. back must
|
|
246
|
+
/// not pop it (mirrors the two early returns in Compose's `onBackSafe`).
|
|
247
|
+
private var currentScreenHandlesBack: Bool {
|
|
248
|
+
let options = currentScreen()?.options
|
|
249
|
+
return options?.onBackHandler != nil || options?.headerBackProps?.preventBack != nil
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/// Asked by the edge-swipe delegate before UIKit starts an interactive pop, so the
|
|
253
|
+
/// gesture ends up where Compose's `BackHandler(true) { onBackSafe() }` does. Returning
|
|
254
|
+
/// `false` means the gesture is swallowed — the side effect it stands for (closing an
|
|
255
|
+
/// overplay, running `onBackHandler`, raising the prevent-back popup) has been run here.
|
|
256
|
+
/// Returning `true` hands the pop to UIKit so the swipe stays interactive; `path.didSet`
|
|
257
|
+
/// then disposes the screen exactly as `pop` would.
|
|
258
|
+
func shouldBeginInteractivePop() -> Bool {
|
|
259
|
+
// A presented screen covers the stack; its own chrome owns the dismissal.
|
|
260
|
+
if presented != nil { return false }
|
|
261
|
+
|
|
262
|
+
if overplay?.type == .snackBar {
|
|
263
|
+
// Same as `pop`: a snackbar doesn't block back, it is cleared on the way out.
|
|
264
|
+
overplayDismissHandler = nil
|
|
265
|
+
overplay = nil
|
|
266
|
+
} else if overplay != nil {
|
|
267
|
+
// Compose: the modal/bottom-sheet BackHandler wins and plays its exit animation.
|
|
268
|
+
hideOverplay()
|
|
269
|
+
return false
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if currentScreenHandlesBack {
|
|
273
|
+
onBackSafe()
|
|
274
|
+
return false
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Nothing of ours left to pop: leave the swipe to whatever stack hosts the kit.
|
|
278
|
+
return !path.isEmpty
|
|
279
|
+
}
|
|
280
|
+
|
|
238
281
|
public func onBackSafe(callback: (() -> Void)? = nil) {
|
|
239
|
-
let latest =
|
|
282
|
+
let latest = currentScreen()
|
|
240
283
|
if let onBack = latest?.options?.onBackHandler {
|
|
241
284
|
onBack()
|
|
242
285
|
return
|
|
@@ -382,4 +425,32 @@ public final class Navigator: ObservableObject {
|
|
|
382
425
|
public func hideSnackBar() {
|
|
383
426
|
if overplay?.type == .snackBar { hideOverplay() }
|
|
384
427
|
}
|
|
428
|
+
|
|
429
|
+
// MARK: Screen disposal
|
|
430
|
+
|
|
431
|
+
/// Matches the `delay(300)` Compose waits in `dismissScreen` before unregistering,
|
|
432
|
+
/// with headroom for the slower interactive swipe.
|
|
433
|
+
private static let screenDisposeDelay: TimeInterval = 0.35
|
|
434
|
+
|
|
435
|
+
private func disposeScreens(droppedFrom previous: [DynamicScreenRoute]) {
|
|
436
|
+
let live = Set(path.map(\.id))
|
|
437
|
+
disposeScreens(previous.map(\.id).filter { !live.contains($0) })
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/// Screens are released only once the pop transition has played out: the outgoing
|
|
441
|
+
/// view is still on screen while it animates and the registry is what renders it —
|
|
442
|
+
/// dropping it right away blanks the screen mid-swipe. Liveness is re-checked when
|
|
443
|
+
/// the timer fires, so a cancelled swipe (path restored) keeps its screen.
|
|
444
|
+
private func disposeScreens(_ ids: [Int]) {
|
|
445
|
+
guard !ids.isEmpty else { return }
|
|
446
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + Self.screenDisposeDelay) { [weak self] in
|
|
447
|
+
guard let self else { return }
|
|
448
|
+
var live = Set(self.path.map(\.id))
|
|
449
|
+
live.insert(self.rootRoute.id)
|
|
450
|
+
if let presented = self.presented { live.insert(presented.id) }
|
|
451
|
+
for id in ids where !live.contains(id) {
|
|
452
|
+
self.registry.unregister(id: id)
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
385
456
|
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
//
|
|
2
|
+
// SwipeBackGesture.swift
|
|
3
|
+
// MoMoUIKits
|
|
4
|
+
//
|
|
5
|
+
// Makes the iOS edge swipe behave like Compose's `BackHandler(true) { navigator.onBackSafe() }`.
|
|
6
|
+
// Every kit screen draws its own header and therefore hides the system back button, which is
|
|
7
|
+
// exactly what makes SwiftUI turn `interactivePopGestureRecognizer` off — so the gesture is
|
|
8
|
+
// re-enabled here and its start is arbitrated by the Navigator.
|
|
9
|
+
//
|
|
10
|
+
|
|
11
|
+
import SwiftUI
|
|
12
|
+
import UIKit
|
|
13
|
+
|
|
14
|
+
@available(iOS 16.0, *)
|
|
15
|
+
extension View {
|
|
16
|
+
/// Re-enables the interactive pop gesture for a `NavigationStack` destination and
|
|
17
|
+
/// routes it through `navigator`. Apply it to every destination: SwiftUI reconfigures
|
|
18
|
+
/// the recognizer as screens come and go, so the install has to be repeated.
|
|
19
|
+
func momoSwipeBack(_ navigator: Navigator) -> some View {
|
|
20
|
+
background(SwipeBackInstaller(navigator: navigator).allowsHitTesting(false))
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
@available(iOS 16.0, *)
|
|
25
|
+
private struct SwipeBackInstaller: UIViewRepresentable {
|
|
26
|
+
let navigator: Navigator
|
|
27
|
+
|
|
28
|
+
func makeUIView(context: Context) -> SwipeBackProbeView {
|
|
29
|
+
let view = SwipeBackProbeView()
|
|
30
|
+
view.navigator = navigator
|
|
31
|
+
return view
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
func updateUIView(_ uiView: SwipeBackProbeView, context: Context) {
|
|
35
|
+
uiView.navigator = navigator
|
|
36
|
+
uiView.install()
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/// Zero-sized view whose only job is to reach the `UINavigationController` backing the
|
|
41
|
+
/// enclosing `NavigationStack` — SwiftUI exposes no other handle on it.
|
|
42
|
+
@available(iOS 16.0, *)
|
|
43
|
+
private final class SwipeBackProbeView: UIView {
|
|
44
|
+
weak var navigator: Navigator?
|
|
45
|
+
|
|
46
|
+
override func didMoveToWindow() {
|
|
47
|
+
super.didMoveToWindow()
|
|
48
|
+
install()
|
|
49
|
+
// SwiftUI applies the destination's bar configuration around the same runloop
|
|
50
|
+
// turn, and it is the thing that disables the recognizer — so claim it again
|
|
51
|
+
// once that has settled.
|
|
52
|
+
DispatchQueue.main.async { [weak self] in self?.install() }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
func install() {
|
|
56
|
+
guard window != nil, let navigator, let controller = navigationController() else { return }
|
|
57
|
+
SwipeBackGestureDelegate.shared.install(on: controller, navigator: navigator)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private func navigationController() -> UINavigationController? {
|
|
61
|
+
var responder: UIResponder? = self
|
|
62
|
+
while let current = responder {
|
|
63
|
+
if let nav = current as? UINavigationController { return nav }
|
|
64
|
+
if let controller = current as? UIViewController, let nav = controller.navigationController { return nav }
|
|
65
|
+
responder = current.next
|
|
66
|
+
}
|
|
67
|
+
return nil
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/// Shared `interactivePopGestureRecognizer` delegate. One instance serves every kit stack
|
|
72
|
+
/// in the process (a bottom-tab surface or a host screen can have its own), keyed by the
|
|
73
|
+
/// navigation controller the recognizer belongs to. Being long-lived matters: the delegate
|
|
74
|
+
/// is held weakly by UIKit, and a nil delegate lets the gesture fire on a single-screen
|
|
75
|
+
/// stack, which corrupts it.
|
|
76
|
+
@available(iOS 16.0, *)
|
|
77
|
+
private final class SwipeBackGestureDelegate: NSObject, UIGestureRecognizerDelegate {
|
|
78
|
+
static let shared = SwipeBackGestureDelegate()
|
|
79
|
+
|
|
80
|
+
private struct Entry {
|
|
81
|
+
weak var controller: UINavigationController?
|
|
82
|
+
weak var navigator: Navigator?
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private var entries: [ObjectIdentifier: Entry] = [:]
|
|
86
|
+
|
|
87
|
+
func install(on controller: UINavigationController, navigator: Navigator) {
|
|
88
|
+
entries = entries.filter { $0.value.controller != nil && $0.value.navigator != nil }
|
|
89
|
+
entries[ObjectIdentifier(controller)] = Entry(controller: controller, navigator: navigator)
|
|
90
|
+
|
|
91
|
+
guard let gesture = controller.interactivePopGestureRecognizer else { return }
|
|
92
|
+
gesture.delegate = self
|
|
93
|
+
gesture.isEnabled = true
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
|
97
|
+
guard let entry = entry(for: gestureRecognizer), let controller = entry.controller else { return false }
|
|
98
|
+
// Guards the classic UIKit hang: popping the only view controller on the stack.
|
|
99
|
+
guard controller.viewControllers.count > 1 else { return false }
|
|
100
|
+
guard let navigator = entry.navigator else { return true }
|
|
101
|
+
return navigator.shouldBeginInteractivePop()
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/// The edge swipe stays exclusive — letting it run alongside a horizontal scroll or a
|
|
105
|
+
/// swipeable cell would pop the screen while the user is dragging inside it.
|
|
106
|
+
func gestureRecognizer(
|
|
107
|
+
_ gestureRecognizer: UIGestureRecognizer,
|
|
108
|
+
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
|
|
109
|
+
) -> Bool {
|
|
110
|
+
false
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private func entry(for gestureRecognizer: UIGestureRecognizer) -> Entry? {
|
|
114
|
+
entries.values.first { $0.controller?.interactivePopGestureRecognizer === gestureRecognizer }
|
|
115
|
+
}
|
|
116
|
+
}
|
package/ios/native-kits.podspec
CHANGED