@momo-kits/native-kits 0.163.1-sp.1-debug → 0.163.1-sp.11-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 +111 -1
- package/CLAUDE.md +19 -0
- package/compose/build.gradle.kts +1 -1
- package/compose/compose.podspec +1 -1
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputSearch.kt +5 -5
- package/compose/src/commonMain/kotlin/vn/momo/kits/layout/Item.kt +4 -2
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/BottomSheet.kt +66 -19
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigator.kt +15 -3
- package/gradle.properties +1 -1
- package/ios/Application/ApplicationEnvironment.swift +14 -6
- package/ios/Application/Components.swift +14 -14
- package/ios/Application/HeaderRight.swift +45 -79
- package/ios/Application/LanguageSource.swift +93 -0
- package/ios/Application/Localize.swift +73 -4
- package/ios/Application/LocalizeModifier.swift +30 -0
- package/ios/Application/Navigation/NavigationContainer.swift +28 -18
- package/ios/Application/Navigation/Navigator.swift +121 -53
- package/ios/Application/Navigation/NavigatorManager.swift +49 -0
- package/ios/Application/Navigation/component/Header.swift +1 -1
- package/ios/Application/Navigation/component/HeaderRightWidthKey.swift +11 -0
- package/ios/Application/Navigation/component/NavigationFooter.swift +90 -0
- package/ios/Application/Navigation/component/NavigationHeaderRight.swift +196 -0
- package/ios/Application/StackScreen.swift +19 -28
- package/ios/Badge/Badge.swift +2 -1
- package/ios/Badge/BadgeRibbon.swift +6 -2
- package/ios/Button/Button.swift +54 -80
- package/ios/Chip/Chip.swift +6 -5
- package/ios/Icon/Icon.swift +13 -4
- package/ios/Input/ErrorView.swift +2 -1
- package/ios/Input/Input.swift +9 -2
- package/ios/Input/InputPhoneNumber.swift +2 -1
- package/ios/Input/InputSearch.swift +3 -2
- package/ios/Input/InputTextArea.swift +6 -1
- package/ios/InputMoney/InputMoney.swift +5 -2
- package/ios/InputOTP/InputOTP.swift +2 -1
- package/ios/Layout/GridContext.swift +30 -0
- package/ios/Layout/Item.swift +42 -0
- package/ios/Layout/Section.swift +134 -0
- package/ios/Popup/PopupNotify.swift +2 -1
- package/ios/ProgressInfo/ProgressInfo.swift +4 -3
- package/ios/Rating/Rating.swift +2 -1
- package/ios/Stepper/Stepper.swift +2 -1
- package/ios/SuggestAction/SuggestAction.swift +3 -2
- package/ios/Tag/Tag.swift +2 -1
- package/ios/Template/TrustBanner/TrustBanner.swift +4 -2
- package/ios/Title/Title.swift +3 -2
- package/ios/Tooltip/Tooltip.swift +2 -1
- package/ios/Typography/FontScale.swift +103 -0
- package/ios/Typography/FontScaleStore.swift +52 -13
- package/ios/Typography/Text.swift +12 -29
- package/ios/Typography/Typography.swift +66 -86
- package/ios/native-kits.podspec +2 -1
- package/ios-demo/MoMoUIKitsDemo.podspec +1 -1
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/AvatarDemo.swift +87 -73
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/BadgeDemo.swift +55 -249
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/ButtonDemo.swift +90 -82
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/CarouselDemo.swift +74 -104
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/CheckboxDemo.swift +47 -50
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/ChipDemo.swift +63 -7
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/CollapseDemo.swift +90 -81
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/DateTimePickerDemo.swift +73 -97
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/DemoScreen.swift +420 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/DividerDemo.swift +46 -4
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/Home.swift +54 -57
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/IconButtonDemo.swift +80 -45
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/IconDemo.swift +81 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/ImageDemo.swift +49 -62
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/InformationDemo.swift +66 -116
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/InputDemo.swift +104 -98
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/InputDropDownDemo.swift +76 -118
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/InputMoneyDemo.swift +73 -86
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/InputOTPDemo.swift +69 -54
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/InputPhoneNumberDemo.swift +74 -54
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/InputSearchDemo.swift +73 -20
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/InputTextAreaDemo.swift +68 -38
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/LoaderDemo.swift +55 -52
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/LocalizeDemo.swift +51 -4
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/NavigationDemo.swift +14 -2
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PaginationDemo.swift +47 -51
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PlaygroundData.swift +76 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PopupNotifyDemo.swift +67 -93
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PopupPromotionDemo.swift +34 -43
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/ProgressInfoDemo.swift +83 -71
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/RadioDemo.swift +55 -23
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/RatingDemo.swift +58 -46
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/SectionDemo.swift +83 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/SkeletonDemo.swift +41 -12
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/SliderDemo.swift +67 -127
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/SnackBarDemo.swift +54 -110
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/StepperDemo.swift +69 -79
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/StepsDemo.swift +78 -58
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/SuggestActionDemo.swift +76 -65
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/SwipeDemo.swift +82 -127
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/SwitchDemo.swift +44 -19
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/TabViewDemo.swift +92 -168
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/TagDemo.swift +79 -27
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/ThemeDemo.swift +0 -1
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/TitleDemo.swift +87 -92
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/TooltipDemo.swift +94 -163
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/TypographyDemo.swift +70 -70
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/UploaderDemo.swift +68 -114
- package/ios-demo/Sources/MoMoUIKitsDemo/SwiftUIDemoBrowserView.swift +3 -1
- package/package.json +2 -2
|
@@ -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
|
+
// Disposal hangs off the path so every route that leaves the stack — pop,
|
|
94
|
+
// replace, reset — is released the same way, after its exit transition.
|
|
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,26 +190,50 @@ 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) {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
197
|
+
popStep(remaining: count, callback: callback)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/// One `pop` iteration. Compose runs the loop in a coroutine and suspends over the
|
|
201
|
+
/// overplay's exit animation before the next step; the same wait is needed here, or
|
|
202
|
+
/// a `pop(2)` (prevent-back: close the popup, then leave) would spend both steps on
|
|
203
|
+
/// the overplay that is still animating out and never pop the screen.
|
|
204
|
+
private func popStep(remaining: Int, callback: (() -> Void)?) {
|
|
205
|
+
guard remaining > 0 else {
|
|
206
|
+
callback?()
|
|
207
|
+
return
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if overplay?.type == .snackBar {
|
|
211
|
+
// Mirrors Kotlin: a snackbar doesn't block back-navigation, so popping
|
|
212
|
+
// while one is showing pops the screen underneath and clears the
|
|
213
|
+
// snackbar immediately, without playing its dismiss animation.
|
|
214
|
+
overplayDismissHandler = nil
|
|
215
|
+
overplay = nil
|
|
216
|
+
guard popScreen() else {
|
|
217
|
+
callback?()
|
|
218
|
+
return
|
|
219
|
+
}
|
|
220
|
+
popStep(remaining: remaining - 1, callback: callback)
|
|
221
|
+
} else if overplay != nil {
|
|
222
|
+
hideOverplay()
|
|
223
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + Self.overplayDismissDelay) { [weak self] in
|
|
224
|
+
guard let self else { return }
|
|
225
|
+
// The component reports back through `finishOverplayDismiss` once its exit
|
|
226
|
+
// animation ends; clear it here if it never did, so the step is not wasted.
|
|
227
|
+
if self.overplay != nil { self.finishOverplayDismiss() }
|
|
228
|
+
self.popStep(remaining: remaining - 1, callback: callback)
|
|
208
229
|
}
|
|
209
|
-
|
|
230
|
+
} else {
|
|
231
|
+
guard popScreen() else {
|
|
232
|
+
callback?()
|
|
233
|
+
return
|
|
234
|
+
}
|
|
235
|
+
popStep(remaining: remaining - 1, callback: callback)
|
|
210
236
|
}
|
|
211
|
-
callback?()
|
|
212
237
|
}
|
|
213
238
|
|
|
214
239
|
/// Pops the topmost presented screen or stack entry. Returns `false` (and requests
|
|
@@ -218,11 +243,10 @@ public final class Navigator: ObservableObject {
|
|
|
218
243
|
if presented != nil {
|
|
219
244
|
let dismissedId = presented?.id
|
|
220
245
|
presented = nil
|
|
221
|
-
if let id = dismissedId {
|
|
246
|
+
if let id = dismissedId { disposeScreens([id]) }
|
|
222
247
|
return true
|
|
223
248
|
} else if !path.isEmpty {
|
|
224
|
-
|
|
225
|
-
registry.unregister(id: removed.id)
|
|
249
|
+
path.removeLast()
|
|
226
250
|
return true
|
|
227
251
|
} else {
|
|
228
252
|
composeApi?.requestCallback(funcName: "dismiss", params: nil, onResponse: nil)
|
|
@@ -231,42 +255,54 @@ public final class Navigator: ObservableObject {
|
|
|
231
255
|
}
|
|
232
256
|
|
|
233
257
|
public func popToRoot() {
|
|
234
|
-
for route in path { registry.unregister(id: route.id) }
|
|
235
258
|
path.removeAll()
|
|
236
259
|
}
|
|
237
260
|
|
|
261
|
+
/// The entry a back action applies to — mirrors Compose's `currentScreen()`
|
|
262
|
+
/// (`navController.currentBackStackEntry`): a presented screen sits on top of
|
|
263
|
+
/// the stack, otherwise it is the last pushed screen, otherwise the root.
|
|
264
|
+
private func currentScreen() -> DynamicScreen? {
|
|
265
|
+
registry.getScreen(id: presented?.id ?? path.last?.id ?? rootRoute.id)
|
|
266
|
+
}
|
|
267
|
+
|
|
238
268
|
public func onBackSafe(callback: (() -> Void)? = nil) {
|
|
239
|
-
let latest =
|
|
269
|
+
let latest = currentScreen()
|
|
240
270
|
if let onBack = latest?.options?.onBackHandler {
|
|
241
271
|
onBack()
|
|
242
272
|
return
|
|
243
273
|
}
|
|
244
274
|
if let preventBack = latest?.options?.headerBackProps?.preventBack {
|
|
245
275
|
showModal {
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
276
|
+
PopupNotify(
|
|
277
|
+
props: PopupNotifyProps(
|
|
278
|
+
image: preventBack.image,
|
|
279
|
+
title: preventBack.title,
|
|
280
|
+
description: preventBack.description,
|
|
281
|
+
error: preventBack.error,
|
|
282
|
+
primary: PopupAction(
|
|
283
|
+
title: preventBack.primary?.title ?? "",
|
|
284
|
+
onPress: { [weak self] in
|
|
285
|
+
preventBack.primary?.onPress?()
|
|
286
|
+
// Close the popup, then leave the screen (matches Compose's pop(2)).
|
|
287
|
+
self?.pop(2, callback: callback)
|
|
288
|
+
}
|
|
289
|
+
),
|
|
290
|
+
secondary: preventBack.secondary.map { secondary in
|
|
291
|
+
PopupAction(
|
|
292
|
+
title: secondary.title,
|
|
293
|
+
onPress: { [weak self] in
|
|
294
|
+
secondary.onPress?()
|
|
295
|
+
self?.hideOverplay()
|
|
296
|
+
}
|
|
297
|
+
)
|
|
298
|
+
},
|
|
299
|
+
buttonDirection: preventBack.buttonDirection,
|
|
300
|
+
onIconClose: { [weak self] in
|
|
301
|
+
preventBack.onIconClose?()
|
|
302
|
+
self?.hideOverplay()
|
|
303
|
+
},
|
|
304
|
+
isShowCloseIcon: preventBack.isShowCloseIcon
|
|
305
|
+
)
|
|
270
306
|
)
|
|
271
307
|
}
|
|
272
308
|
return
|
|
@@ -382,4 +418,36 @@ public final class Navigator: ObservableObject {
|
|
|
382
418
|
public func hideSnackBar() {
|
|
383
419
|
if overplay?.type == .snackBar { hideOverplay() }
|
|
384
420
|
}
|
|
421
|
+
|
|
422
|
+
// MARK: Screen disposal
|
|
423
|
+
|
|
424
|
+
/// Matches the `delay(300)` Compose waits in `dismissScreen` before unregistering,
|
|
425
|
+
/// with a little headroom for the pop transition.
|
|
426
|
+
private static let screenDisposeDelay: TimeInterval = 0.35
|
|
427
|
+
|
|
428
|
+
/// Longest overplay exit animation (the bottom sheet's), so a queued pop step runs
|
|
429
|
+
/// after the component has taken itself down.
|
|
430
|
+
private static let overplayDismissDelay: TimeInterval = 0.3
|
|
431
|
+
|
|
432
|
+
private func disposeScreens(droppedFrom previous: [DynamicScreenRoute]) {
|
|
433
|
+
let live = Set(path.map(\.id))
|
|
434
|
+
disposeScreens(previous.map(\.id).filter { !live.contains($0) })
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/// Screens are released only once the pop transition has played out: the outgoing
|
|
438
|
+
/// view is still on screen while it animates and the registry is what renders it —
|
|
439
|
+
/// dropping it right away blanks the screen mid-transition. Liveness is re-checked
|
|
440
|
+
/// when the timer fires, so a route pushed back before then keeps its screen.
|
|
441
|
+
private func disposeScreens(_ ids: [Int]) {
|
|
442
|
+
guard !ids.isEmpty else { return }
|
|
443
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + Self.screenDisposeDelay) { [weak self] in
|
|
444
|
+
guard let self else { return }
|
|
445
|
+
var live = Set(self.path.map(\.id))
|
|
446
|
+
live.insert(self.rootRoute.id)
|
|
447
|
+
if let presented = self.presented { live.insert(presented.id) }
|
|
448
|
+
for id in ids where !live.contains(id) {
|
|
449
|
+
self.registry.unregister(id: id)
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
385
453
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
//
|
|
2
|
+
// NavigatorManager.swift
|
|
3
|
+
// MoMoUIKits
|
|
4
|
+
//
|
|
5
|
+
// Host bridge for the native iOS edge-swipe back — the SwiftUI mirror of Compose's
|
|
6
|
+
// `ComposeNavigatorManager` (momo-app `vn.momo.compose.uikits.ComposeNavigatorManager`).
|
|
7
|
+
//
|
|
8
|
+
// The kit does NOT own the swipe gesture. In the host app a kit screen lives inside a
|
|
9
|
+
// `UIHostingController` hosted by the mini-app container VC, and that VC owns the
|
|
10
|
+
// `UIScreenEdgePanGestureRecognizer`. When the swipe fires the host asks this manager
|
|
11
|
+
// whether a kit stack is mounted (`checkCanPop`) and, if so, routes the back into it
|
|
12
|
+
// (`pop` → `Navigator.onBackSafe`) instead of dismissing the whole mini-app. Standalone
|
|
13
|
+
// (no such host, e.g. the demo app) there is no edge swipe — same as Compose on iOS,
|
|
14
|
+
// whose `BackHandler` is a no-op and which relies entirely on the host.
|
|
15
|
+
//
|
|
16
|
+
|
|
17
|
+
import Foundation
|
|
18
|
+
|
|
19
|
+
@available(iOS 16.0, *)
|
|
20
|
+
public final class NavigatorManager {
|
|
21
|
+
public static let shared = NavigatorManager()
|
|
22
|
+
private init() {}
|
|
23
|
+
|
|
24
|
+
/// Stack of mounted navigators; the last one is the visible top (nested mini-apps
|
|
25
|
+
/// push more than one). Held strongly, mirroring `ComposeNavigatorManager` — entries
|
|
26
|
+
/// are released by `NavigationContainer` on disappear, not by ARC.
|
|
27
|
+
private var navigators: [Navigator] = []
|
|
28
|
+
|
|
29
|
+
/// Idempotent: SwiftUI can re-run a view's `onAppear`, and a double push would need a
|
|
30
|
+
/// matching double remove to unwind.
|
|
31
|
+
public func push(_ navigator: Navigator) {
|
|
32
|
+
guard !navigators.contains(where: { $0 === navigator }) else { return }
|
|
33
|
+
navigators.append(navigator)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/// Removes by identity rather than `removeLast` so a spurious disappear of a covered
|
|
37
|
+
/// (non-top) container can't drop the wrong navigator.
|
|
38
|
+
public func remove(_ navigator: Navigator) {
|
|
39
|
+
navigators.removeAll { $0 === navigator }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/// True while any kit stack is mounted — tells the host to route the back into the kit
|
|
43
|
+
/// instead of dismissing the mini-app. Matches `ComposeNavigatorManager.checkCanPop`:
|
|
44
|
+
/// it does not inspect stack depth; `onBackSafe` handles the at-root case itself by
|
|
45
|
+
/// asking the host to dismiss (via `composeApi`).
|
|
46
|
+
public func checkCanPop() -> Bool { !navigators.isEmpty }
|
|
47
|
+
|
|
48
|
+
public func pop() { navigators.last?.onBackSafe() }
|
|
49
|
+
}
|
|
@@ -22,7 +22,7 @@ public struct NavigationHeader: View {
|
|
|
22
22
|
/// Optional rich title (mirrors Compose's `HeaderTitle`); `.user` renders an
|
|
23
23
|
/// avatar group, `.default`/nil renders plain `title`.
|
|
24
24
|
var headerTitle: HeaderTitle?
|
|
25
|
-
var headerRight: (() -> any View)? = {
|
|
25
|
+
var headerRight: (() -> any View)? = { NavigationHeaderRight() }
|
|
26
26
|
var goBack: (() -> Void)?
|
|
27
27
|
var opacity: CGFloat = 1
|
|
28
28
|
var animatedHeader: AnimatedHeader?
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
|
|
3
|
+
/// Reports the measured width of the header-right area so the animated search
|
|
4
|
+
/// header can compute its trailing inset (mirrors Compose's headerRightWidthPx).
|
|
5
|
+
/// Written by the header views (`NavigationHeader`) and read by `StackScreen`.
|
|
6
|
+
public struct HeaderRightWidthKey: PreferenceKey {
|
|
7
|
+
public static var defaultValue: CGFloat = 0
|
|
8
|
+
public static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
|
|
9
|
+
value = max(value, nextValue())
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
//
|
|
2
|
+
// NavigationFooter.swift
|
|
3
|
+
// MoMoUIKits
|
|
4
|
+
//
|
|
5
|
+
// SwiftUI mirror of Compose's `Footer` (navigation/StackScreen.kt): the band
|
|
6
|
+
// pinned under the screen content — surface background, S/M padding, the
|
|
7
|
+
// navigation-bar reserve, and the 6pt gradient shadow cast up onto the content.
|
|
8
|
+
//
|
|
9
|
+
// Named `NavigationFooter` to coexist with the legacy `Footer` in `Screen.swift`
|
|
10
|
+
// (kept as-is for the old `Screen` path). Used by `StackScreen`.
|
|
11
|
+
//
|
|
12
|
+
|
|
13
|
+
import SwiftUI
|
|
14
|
+
|
|
15
|
+
public struct NavigationFooter: View {
|
|
16
|
+
/// Compose's `footerComponent`: nothing renders when nil.
|
|
17
|
+
var footerComponent: (() -> AnyView)?
|
|
18
|
+
/// Current keyboard height, measured from the bottom of the screen.
|
|
19
|
+
var keyboardSize: CGFloat
|
|
20
|
+
|
|
21
|
+
public init(
|
|
22
|
+
footerComponent: (() -> AnyView)?,
|
|
23
|
+
keyboardSize: CGFloat = 0
|
|
24
|
+
) {
|
|
25
|
+
self.footerComponent = footerComponent
|
|
26
|
+
self.keyboardSize = keyboardSize
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
@Environment(\.appTheme) private var theme
|
|
30
|
+
|
|
31
|
+
/// Read from the window, not from an enclosing `GeometryProxy`: the proxy's bottom
|
|
32
|
+
/// inset grows to the keyboard height while the keyboard is up.
|
|
33
|
+
static var safeAreaBottom: CGFloat {
|
|
34
|
+
UIApplication.shared.connectedScenes
|
|
35
|
+
.compactMap { ($0 as? UIWindowScene)?.keyWindow?.safeAreaInsets.bottom }
|
|
36
|
+
.first ?? 0
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/// Compose's `AppNavigationBar.current` (`getNavigationBarHeight` clamps iOS to 21pt).
|
|
40
|
+
static var navigationBarInset: CGFloat { min(safeAreaBottom, 21) }
|
|
41
|
+
|
|
42
|
+
/// Compose's `bottomPadding + Spacing.S` with the keyboard down. The footer is laid
|
|
43
|
+
/// out inside the safe area, which already covers part of that gap.
|
|
44
|
+
static var bottomPadding: CGFloat {
|
|
45
|
+
max(navigationBarInset + Spacing.S - safeAreaBottom, 0)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/// Deliberately an offset and not padding: growing the footer feeds back into SwiftUI's
|
|
49
|
+
/// keyboard avoidance, so a padding-based lift never settles at the right place.
|
|
50
|
+
/// Negative lifts the band over the keyboard; positive shaves the few points by which
|
|
51
|
+
/// the safe area over-reserves compared to Compose. `FloatingContent` rides the same
|
|
52
|
+
/// amount so the FAB keeps its distance above the band.
|
|
53
|
+
///
|
|
54
|
+
/// Natural position is `safeAreaBottom + bottomPadding` above the bottom of the screen;
|
|
55
|
+
/// Compose puts it at `(AppNavigationBar - keyboard).coerceAtLeast(0) + Spacing.S` on
|
|
56
|
+
/// top of the `imePadding()` lift — i.e. `max(navigationBar, keyboard) + Spacing.S`.
|
|
57
|
+
static func verticalOffset(keyboardSize: CGFloat) -> CGFloat {
|
|
58
|
+
(safeAreaBottom + bottomPadding) - (max(navigationBarInset, keyboardSize) + Spacing.S)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
public var body: some View {
|
|
62
|
+
if let footerComponent = footerComponent {
|
|
63
|
+
VStack(alignment: .leading, spacing: 0) {
|
|
64
|
+
footerComponent()
|
|
65
|
+
}
|
|
66
|
+
.frame(maxWidth: .infinity, alignment: .leading)
|
|
67
|
+
.padding(.top, Spacing.S)
|
|
68
|
+
.padding(.horizontal, Spacing.M)
|
|
69
|
+
.padding(.bottom, Self.bottomPadding)
|
|
70
|
+
// Compose paints the surface all the way to the bottom of the screen; the
|
|
71
|
+
// footer's own frame stops at the safe area, so only the fill bleeds down.
|
|
72
|
+
.background(theme.colors.background.surface.ignoresSafeArea(.container, edges: .bottom))
|
|
73
|
+
.overlay(alignment: .top) { shadow }
|
|
74
|
+
.offset(y: Self.verticalOffset(keyboardSize: keyboardSize))
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/// Compose offsets the shadow box by -6dp, so it lands on the content above the
|
|
79
|
+
/// footer rather than on the footer surface itself.
|
|
80
|
+
private var shadow: some View {
|
|
81
|
+
LinearGradient(
|
|
82
|
+
colors: [Color.clear, Color.black.opacity(0.05)],
|
|
83
|
+
startPoint: .top,
|
|
84
|
+
endPoint: .bottom
|
|
85
|
+
)
|
|
86
|
+
.frame(height: 6)
|
|
87
|
+
.offset(y: -6)
|
|
88
|
+
.allowsHitTesting(false)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
import Foundation
|
|
3
|
+
|
|
4
|
+
/// Keyed (`@Environment(\.applicationEnvironment)`) variant of `HeaderRight`, for hosts
|
|
5
|
+
/// wired through `NavigationContainer`/`KitContainer` (the injection style introduced by
|
|
6
|
+
/// MR 227 / DS-637). Reuses the shared data types (`HeaderRightData`, `Tool`, `ToolGroup`)
|
|
7
|
+
/// and `NavigationButton` from `HeaderRight.swift`. The legacy `HeaderRight` there stays on
|
|
8
|
+
/// `@EnvironmentObject` for hosts that render `Screen` directly with
|
|
9
|
+
/// `.environmentObject(ApplicationEnvironment(...))`. Opt in per screen with
|
|
10
|
+
/// `headerRight: { NavigationHeaderRight() }`.
|
|
11
|
+
public struct NavigationHeaderRight: View {
|
|
12
|
+
public init(headerRight: HeaderRightData? = nil, tintColor: Color? = nil) {
|
|
13
|
+
self.headerRight = headerRight
|
|
14
|
+
self.tintColor = tintColor
|
|
15
|
+
}
|
|
16
|
+
var headerRight: HeaderRightData?
|
|
17
|
+
var tintColor: Color? = nil
|
|
18
|
+
|
|
19
|
+
public var body: some View {
|
|
20
|
+
HStack(alignment: .center) {
|
|
21
|
+
ToolkitNavigationHeaderRight(headerRight: headerRight, tintColor: tintColor)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
struct ToolkitNavigationHeaderRight: View {
|
|
27
|
+
var headerRight: HeaderRightData?
|
|
28
|
+
var tintColor: Color?
|
|
29
|
+
@State private var isFavorite: Bool = false
|
|
30
|
+
@State private var isLoading: Bool = false
|
|
31
|
+
@Environment(\.applicationEnvironment) private var environment
|
|
32
|
+
|
|
33
|
+
private func apiValue(_ value: Any?) -> Any {
|
|
34
|
+
value ?? NSNull()
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
private var contextMap: [String: Any] {
|
|
38
|
+
let context = environment.applicationContext
|
|
39
|
+
return [
|
|
40
|
+
"appId": apiValue(context?.appId),
|
|
41
|
+
"code": apiValue(context?.appCode),
|
|
42
|
+
"name": apiValue(context?.appName),
|
|
43
|
+
"icon": apiValue(context?.appIcon),
|
|
44
|
+
"description": apiValue(context?.description),
|
|
45
|
+
"support": apiValue(context?.support),
|
|
46
|
+
"toolkitConfig": apiValue(context?.toolkitConfig),
|
|
47
|
+
"providerId": apiValue(context?.providerId),
|
|
48
|
+
"permissions": apiValue(context?.permissions)
|
|
49
|
+
]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private var shouldShowShortcut: Bool {
|
|
53
|
+
headerRight?.useShortcut == true && !(headerRight?.useMore == true && environment.applicationContext == nil)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private func parseResponse(_ response: String) -> Any? {
|
|
57
|
+
guard let jsonData = response.data(using: .utf8),
|
|
58
|
+
let json = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any] else {
|
|
59
|
+
return nil
|
|
60
|
+
}
|
|
61
|
+
return json["response"]
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// MARK: - Actions
|
|
65
|
+
private func loadFavoriteState() {
|
|
66
|
+
environment.composeApi?.request(
|
|
67
|
+
funcName: "isFavoriteApp",
|
|
68
|
+
params: ["code": apiValue(environment.applicationContext?.appCode)]
|
|
69
|
+
) { response in
|
|
70
|
+
if let isFavorite = parseResponse(response) as? Bool {
|
|
71
|
+
self.isFavorite = isFavorite
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private func onPressShortcut() {
|
|
77
|
+
environment.composeApi?.request(
|
|
78
|
+
funcName: "onToolAction",
|
|
79
|
+
params: [
|
|
80
|
+
"item": ["key": "onFavorite"],
|
|
81
|
+
"context": contextMap
|
|
82
|
+
]
|
|
83
|
+
) { response in
|
|
84
|
+
if let result = parseResponse(response) as? [String: Any],
|
|
85
|
+
result["success"] as? Bool == true {
|
|
86
|
+
isFavorite.toggle()
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private func onPressHelpCenter() {
|
|
92
|
+
environment.composeApi?.request(
|
|
93
|
+
funcName: "showHelpCenter",
|
|
94
|
+
params: [
|
|
95
|
+
"appId": apiValue(environment.applicationContext?.appId),
|
|
96
|
+
"code": apiValue(environment.applicationContext?.appCode),
|
|
97
|
+
"name": apiValue(environment.applicationContext?.appName),
|
|
98
|
+
"icon": apiValue(environment.applicationContext?.appIcon),
|
|
99
|
+
"description": apiValue(environment.applicationContext?.description)
|
|
100
|
+
]
|
|
101
|
+
) { _ in }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private func onPressClose() {
|
|
105
|
+
environment.composeApi?.request(
|
|
106
|
+
funcName: "dismissAll",
|
|
107
|
+
params: nil
|
|
108
|
+
) { _ in }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private func onPressMore() {
|
|
112
|
+
let params: [String: Any] = [
|
|
113
|
+
"useSystemTools": headerRight?.useSystemTools ?? true,
|
|
114
|
+
"tools": headerRight?.tools.map { $0.toMap() } ?? [],
|
|
115
|
+
"context": contextMap
|
|
116
|
+
]
|
|
117
|
+
environment.composeApi?.request(
|
|
118
|
+
funcName: "showTools",
|
|
119
|
+
params: params
|
|
120
|
+
) { response in
|
|
121
|
+
if let toolResponse = parseResponse(response) as? String {
|
|
122
|
+
headerRight?.toolCallback?(toolResponse)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private func getNavigationButtonConfig() -> NavigationButtonConfig {
|
|
128
|
+
let totalTools = headerRight?.tools.reduce(0) { $0 + $1.items.count } ?? 0
|
|
129
|
+
var icon = isFavorite ? "pin_star_checked" : "pin_star"
|
|
130
|
+
var onClickHandler: () -> Void = onPressShortcut
|
|
131
|
+
|
|
132
|
+
if totalTools > 1 || headerRight?.useMore == true {
|
|
133
|
+
icon = "navigation_more_icon"
|
|
134
|
+
onClickHandler = onPressMore
|
|
135
|
+
} else if totalTools == 1, let singleTool = headerRight?.tools.first?.items.first {
|
|
136
|
+
icon = singleTool.icon
|
|
137
|
+
onClickHandler = {
|
|
138
|
+
headerRight?.toolCallback?(singleTool.key)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return NavigationButtonConfig(icon: icon, onPress: onClickHandler)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
var body: some View {
|
|
146
|
+
let backgroundButtonColor: Color = tintColor == Colors.black01 ? Colors.black20.opacity(0.6) : Colors.black01.opacity(0.6)
|
|
147
|
+
let borderColor: Color = tintColor == Colors.black01 ? Colors.black01.opacity(0.2) : Colors.black20.opacity(0.2)
|
|
148
|
+
let navButtonConfig = getNavigationButtonConfig()
|
|
149
|
+
let showBadge = headerRight?.tools.contains { group in
|
|
150
|
+
group.items.contains { $0.showBadge }
|
|
151
|
+
} ?? false
|
|
152
|
+
|
|
153
|
+
HStack(alignment: .center, spacing: 0) {
|
|
154
|
+
if shouldShowShortcut {
|
|
155
|
+
NavigationButton(
|
|
156
|
+
disabled: isLoading,
|
|
157
|
+
icon: navButtonConfig.icon,
|
|
158
|
+
showBadge: showBadge,
|
|
159
|
+
onClick: navButtonConfig.onPress,
|
|
160
|
+
tintColor: tintColor
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
HStack(alignment: .center, spacing: 0) {
|
|
165
|
+
if environment.applicationContext != nil {
|
|
166
|
+
Icon(source: "help_center", size: 20, color: tintColor ?? Colors.black17)
|
|
167
|
+
.padding(4)
|
|
168
|
+
.onTapGesture {
|
|
169
|
+
onPressHelpCenter()
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
Rectangle()
|
|
173
|
+
.fill(tintColor ?? Colors.black20)
|
|
174
|
+
.frame(width: 0.5, height: 12)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
Icon(source: "16_basic_home", size: 20, color: tintColor ?? Colors.black17)
|
|
178
|
+
.padding(4)
|
|
179
|
+
.onTapGesture {
|
|
180
|
+
onPressClose()
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
.frame(width: 65, height: 28)
|
|
184
|
+
.background(backgroundButtonColor)
|
|
185
|
+
.cornerRadius(14)
|
|
186
|
+
.overlay(
|
|
187
|
+
RoundedRectangle(cornerRadius: 14)
|
|
188
|
+
.stroke(borderColor, lineWidth: 0.2)
|
|
189
|
+
)
|
|
190
|
+
.padding(.leading, Spacing.S)
|
|
191
|
+
}
|
|
192
|
+
.onAppear {
|
|
193
|
+
loadFavoriteState()
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|