@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.
@@ -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
  }
@@ -40,7 +40,7 @@ kotlin {
40
40
  }
41
41
 
42
42
  cocoapods {
43
- version = "0.163.1-sp.5-debug"
43
+ version = "0.163.1-sp.6-debug"
44
44
  summary = "IOS Shared module"
45
45
  homepage = "https://momo.vn"
46
46
  ios.deploymentTarget = "15.0"
@@ -1,6 +1,6 @@
1
1
  Pod::Spec.new do |spec|
2
2
  spec.name = 'compose'
3
- spec.version = '0.163.1-sp.5-debug'
3
+ spec.version = '0.163.1-sp.6-debug'
4
4
  spec.homepage = 'https://momo.vn'
5
5
  spec.source = { :http=> ''}
6
6
  spec.authors = ''
package/gradle.properties CHANGED
@@ -18,7 +18,7 @@ kotlin.apple.xcodeCompatibility.nowarn=true
18
18
  name="ComposeKits"
19
19
  group=vn.momo.kits
20
20
  artifact.id=kits
21
- version=0.163.1-sp.5
21
+ version=0.163.1-sp.6
22
22
 
23
23
  repo=GitLab
24
24
  url=https://gitlab.mservice.com.vn/api/v4/projects/5400/packages/maven
@@ -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
- let removed = path.removeLast()
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
- let removed = path.removeLast()
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
- for route in path { registry.unregister(id: route.id) }
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
- for route in path { registry.unregister(id: route.id) }
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 { registry.unregister(id: id) }
223
+ if let id = dismissedId { disposeScreens([id]) }
222
224
  return true
223
225
  } else if !path.isEmpty {
224
- let removed = path.removeLast()
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 = registry.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
+ }
@@ -1,6 +1,6 @@
1
1
  Pod::Spec.new do |s|
2
2
  s.name = 'MoMoUIKits'
3
- s.version = '0.163.1-sp.5'
3
+ s.version = '0.163.1-sp.6'
4
4
  s.summary = 'MoMoUIKits for iOS'
5
5
  s.homepage = 'https://momo.vn'
6
6
  s.license = { :type => 'MIT' }
@@ -1,6 +1,6 @@
1
1
  Pod::Spec.new do |s|
2
2
  s.name = 'MoMoUIKitsDemo'
3
- s.version = '0.163.1-sp.5'
3
+ s.version = '0.163.1-sp.6'
4
4
  s.summary = 'Demo browser for MoMoUIKits SwiftUI components'
5
5
  s.homepage = 'https://momo.vn'
6
6
  s.license = { :type => 'MIT' }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@momo-kits/native-kits",
3
- "version": "0.163.1-sp.5-debug",
3
+ "version": "0.163.1-sp.6-debug",
4
4
  "private": false,
5
5
  "dependencies": {},
6
6
  "devDependencies": {},