@momo-kits/native-kits 0.162.30-debug → 0.162.31-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.md +19 -0
- package/compose/build.gradle.kts +1 -1
- package/compose/compose.podspec +1 -1
- package/gradle.properties +1 -1
- package/ios/Application/ApplicationEnvironment.swift +14 -6
- 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/NavigationFooter.swift +90 -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 +41 -61
- 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
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// SwiftUI mirror of Compose's `StackScreen`. This is a refactor of `Screen`:
|
|
6
6
|
// the body is decomposed into the same focused pieces Compose uses — header
|
|
7
7
|
// layer, scrollable content (`StackScreenContent`), floating button
|
|
8
|
-
// (`FloatingContent`) and footer (`
|
|
8
|
+
// (`FloatingContent`) and footer (`NavigationFooter`) — instead of one
|
|
9
9
|
// monolithic, partially-duplicated view. The public init matches `Screen`
|
|
10
10
|
// so call sites (e.g. NavigationContainer's ScreenHost) can swap over.
|
|
11
11
|
//
|
|
@@ -143,13 +143,8 @@ public struct StackScreen<Content: View>: View {
|
|
|
143
143
|
// MARK: - Body
|
|
144
144
|
|
|
145
145
|
public var body: some View {
|
|
146
|
-
GeometryReader {
|
|
146
|
+
GeometryReader { _ in
|
|
147
147
|
let keyboardSize: CGFloat = useAvoidKeyboard ? max(keyboardHeight, 0) : 0
|
|
148
|
-
let bottomInset = min(geometry.safeAreaInsets.bottom, 21)
|
|
149
|
-
// Skip this screen's own bottom-inset reservation when hosting a footer
|
|
150
|
-
// OR a self-contained BottomTab surface (mirrors Compose's
|
|
151
|
-
// `if (options.footerComponent != null || isBottomTab) 0.dp else navigationBar`).
|
|
152
|
-
let contentBottomInset = (footer == nil && !isBottomTabRoot) ? bottomInset : 0
|
|
153
148
|
|
|
154
149
|
ZStack(alignment: .top) {
|
|
155
150
|
// Background color
|
|
@@ -161,16 +156,16 @@ public struct StackScreen<Content: View>: View {
|
|
|
161
156
|
VStack(alignment: horizontalAlignment, spacing: 0) {
|
|
162
157
|
headerAndContent
|
|
163
158
|
}
|
|
164
|
-
.padding(.bottom, contentBottomInset)
|
|
165
159
|
|
|
166
160
|
FloatingContent(
|
|
167
161
|
fabProps: fabProps,
|
|
168
|
-
|
|
162
|
+
keyboardSize: keyboardSize,
|
|
163
|
+
hasFooter: footer != nil,
|
|
169
164
|
scrollOffset: scrollOffset
|
|
170
165
|
)
|
|
171
166
|
}
|
|
172
167
|
|
|
173
|
-
|
|
168
|
+
NavigationFooter(footerComponent: footer, keyboardSize: keyboardSize)
|
|
174
169
|
}
|
|
175
170
|
|
|
176
171
|
// Animated search bar overlay (mirrors Compose's SearchAnimated layer).
|
|
@@ -444,17 +439,29 @@ private struct SearchAnimatedHeader: View {
|
|
|
444
439
|
/// Mirrors Compose's `FloatingContent`: no FAB props means nothing renders.
|
|
445
440
|
private struct FloatingContent: View {
|
|
446
441
|
let fabProps: FabProps?
|
|
447
|
-
let
|
|
442
|
+
let keyboardSize: CGFloat
|
|
443
|
+
/// With a footer the FAB is anchored to the footer's *layout* top, which does not move
|
|
444
|
+
/// when the band offsets itself over the keyboard — so it rides the same offset.
|
|
445
|
+
let hasFooter: Bool
|
|
448
446
|
let scrollOffset: CGFloat
|
|
449
447
|
|
|
448
|
+
/// Compose keeps the FAB `Spacing.M + AppNavigationBar` above the bottom of the screen
|
|
449
|
+
/// and lifts it with `imePadding()`; here it sits `Spacing.M` above the safe area and
|
|
450
|
+
/// nothing lifts it, so the difference is applied as an offset (see NavigationFooter).
|
|
451
|
+
private var verticalOffset: CGFloat {
|
|
452
|
+
if hasFooter { return NavigationFooter.verticalOffset(keyboardSize: keyboardSize) }
|
|
453
|
+
return NavigationFooter.safeAreaBottom - NavigationFooter.navigationBarInset - keyboardSize
|
|
454
|
+
}
|
|
455
|
+
|
|
450
456
|
var body: some View {
|
|
451
457
|
if let fabProps = fabProps {
|
|
452
458
|
FloatingButton(
|
|
453
459
|
props: fabProps,
|
|
454
|
-
keyboardOffset:
|
|
460
|
+
keyboardOffset: 0,
|
|
455
461
|
scrollOffset: scrollOffset
|
|
456
462
|
)
|
|
457
463
|
.padding(.horizontal, Spacing.M)
|
|
464
|
+
.offset(y: verticalOffset)
|
|
458
465
|
.ignoresSafeArea(.keyboard, edges: .bottom)
|
|
459
466
|
}
|
|
460
467
|
}
|
|
@@ -483,19 +490,3 @@ private struct HideKeyboardOnTap: ViewModifier {
|
|
|
483
490
|
}
|
|
484
491
|
}
|
|
485
492
|
}
|
|
486
|
-
|
|
487
|
-
// MARK: - Footer content
|
|
488
|
-
|
|
489
|
-
/// Mirrors Compose's `FooterContent`: renders the footer chrome only when a
|
|
490
|
-
/// footer component is supplied. Reuses the existing `Footer` view from Screen.swift.
|
|
491
|
-
private struct FooterContent: View {
|
|
492
|
-
let footer: (() -> AnyView)?
|
|
493
|
-
let keyboardSize: CGFloat
|
|
494
|
-
let bottomInset: CGFloat
|
|
495
|
-
|
|
496
|
-
var body: some View {
|
|
497
|
-
if let footerView = footer?() {
|
|
498
|
-
Footer(footerView: footerView, keyboardSize: keyboardSize, bottomInset: bottomInset)
|
|
499
|
-
}
|
|
500
|
-
}
|
|
501
|
-
}
|
package/ios/Badge/Badge.swift
CHANGED
|
@@ -2,6 +2,7 @@ import SwiftUI
|
|
|
2
2
|
|
|
3
3
|
public struct Badge: View {
|
|
4
4
|
@Environment(\.appTheme) private var theme
|
|
5
|
+
@Environment(\.momoFontScale) private var fontScale
|
|
5
6
|
let label: String
|
|
6
7
|
let backgroundColor: Color?
|
|
7
8
|
|
|
@@ -55,7 +56,7 @@ public struct Badge: View {
|
|
|
55
56
|
public var body: some View {
|
|
56
57
|
MomoText(formatTitle(label), typography: .actionXxsBold, color: Colors.black01)
|
|
57
58
|
.padding(.horizontal, Spacing.XS)
|
|
58
|
-
.frame(minWidth:
|
|
59
|
+
.frame(minWidth: fontScale(16), minHeight: fontScale(16))
|
|
59
60
|
.background(badgeColor)
|
|
60
61
|
.cornerRadius(Radius.M)
|
|
61
62
|
.overlay(
|
|
@@ -174,6 +174,7 @@ public struct BadgeRibbon: View {
|
|
|
174
174
|
)
|
|
175
175
|
.frame(width: RibbonConstants.headTailWidth, height: RibbonConstants.headTailHeight)
|
|
176
176
|
.rotationEffect(.degrees(180))
|
|
177
|
+
.offset(x: 1)
|
|
177
178
|
.clipped()
|
|
178
179
|
}
|
|
179
180
|
|
|
@@ -183,6 +184,7 @@ public struct BadgeRibbon: View {
|
|
|
183
184
|
placeholder: AnyView(Color.clear)
|
|
184
185
|
)
|
|
185
186
|
.frame(width: RibbonConstants.headTailWidth, height: RibbonConstants.headTailHeight)
|
|
187
|
+
.offset(x: 1)
|
|
186
188
|
.clipped()
|
|
187
189
|
}
|
|
188
190
|
|
|
@@ -192,6 +194,7 @@ public struct BadgeRibbon: View {
|
|
|
192
194
|
placeholder: AnyView(Color.clear)
|
|
193
195
|
)
|
|
194
196
|
.frame(width: RibbonConstants.skewTailWidth, height: RibbonConstants.skewTailHeight)
|
|
197
|
+
.offset(x: -2)
|
|
195
198
|
.clipped()
|
|
196
199
|
}
|
|
197
200
|
}
|
|
@@ -201,6 +204,7 @@ public struct BadgeRibbon: View {
|
|
|
201
204
|
/// rotation) — the label gets the same scale applied a second time so it stays right-reading
|
|
202
205
|
/// while only the ribbon shape flips.
|
|
203
206
|
public struct RoundedBadgeRibbon: View {
|
|
207
|
+
@Environment(\.momoFontScale) private var fontScale
|
|
204
208
|
let text: String
|
|
205
209
|
let position: RibbonPosition
|
|
206
210
|
|
|
@@ -219,7 +223,7 @@ public struct RoundedBadgeRibbon: View {
|
|
|
219
223
|
}
|
|
220
224
|
|
|
221
225
|
private var measuredTextSize: CGSize {
|
|
222
|
-
let fontSize =
|
|
226
|
+
let fontSize = fontScale(TypographyStyle.labelXsMedium.fontSize)
|
|
223
227
|
let font = UIFont.systemFont(ofSize: fontSize, weight: .medium)
|
|
224
228
|
return text.size(withAttributes: [.font: font])
|
|
225
229
|
}
|
|
@@ -281,7 +285,7 @@ private struct RibbonConstants {
|
|
|
281
285
|
static let ribbonHeight: CGFloat = 20
|
|
282
286
|
static let roundHeight: CGFloat = 16
|
|
283
287
|
static let skewBodyHeight: CGFloat = 16
|
|
284
|
-
static let roundRightRadius: CGFloat =
|
|
288
|
+
static let roundRightRadius: CGFloat = 8
|
|
285
289
|
static let roundPaddingEnd: CGFloat = 6
|
|
286
290
|
static let skewTailWidth: CGFloat = 8
|
|
287
291
|
static let skewTailHeight: CGFloat = 16
|