@momo-kits/native-kits 0.163.1-sp.8-debug → 0.163.2-beta.1-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/.codegraph/.gitignore +5 -0
- package/CLAUDE.md +0 -10
- package/compose/build.gradle.kts +1 -1
- package/compose/compose.podspec +1 -1
- package/example/ios/Example.xcodeproj/xcuserdata/sonnguyen.xcuserdatad/xcschemes/Example.xcscheme +32 -0
- package/example/ios/Example.xcodeproj/xcuserdata/sonnguyen.xcuserdatad/xcschemes/xcschememanagement.plist +14 -0
- package/example/ios/Example.xcworkspace/xcuserdata/sonnguyen.xcuserdatad/UserInterfaceState.xcuserstate +0 -0
- package/example/ios/Example.xcworkspace/xcuserdata/sonnguyen.xcuserdatad/WorkspaceSettings.xcsettings +16 -0
- package/example/ios/Example.xcworkspace/xcuserdata/sonnguyen.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist +6 -0
- package/example/ios/Example.xcworkspace/xcuserdata/sonnguyen.xcuserdatad/xcschemes/xcschememanagement.plist +5 -0
- package/example/ios/Pods/Pods.xcodeproj/xcuserdata/sonnguyen.xcuserdatad/xcschemes/MoMoUIKits.xcscheme +58 -0
- package/example/ios/Pods/Pods.xcodeproj/xcuserdata/sonnguyen.xcuserdatad/xcschemes/Pods-Example.xcscheme +58 -0
- package/example/ios/Pods/Pods.xcodeproj/xcuserdata/sonnguyen.xcuserdatad/xcschemes/SDWebImage.xcscheme +58 -0
- package/example/ios/Pods/Pods.xcodeproj/xcuserdata/sonnguyen.xcuserdatad/xcschemes/SDWebImageSwiftUI.xcscheme +58 -0
- package/example/ios/Pods/Pods.xcodeproj/xcuserdata/sonnguyen.xcuserdatad/xcschemes/SkeletonUI.xcscheme +58 -0
- package/example/ios/Pods/Pods.xcodeproj/xcuserdata/sonnguyen.xcuserdatad/xcschemes/lottie-ios-LottiePrivacyInfo.xcscheme +58 -0
- package/example/ios/Pods/Pods.xcodeproj/xcuserdata/sonnguyen.xcuserdatad/xcschemes/lottie-ios.xcscheme +58 -0
- package/example/ios/Pods/Pods.xcodeproj/xcuserdata/sonnguyen.xcuserdatad/xcschemes/xcschememanagement.plist +60 -0
- package/gradle.properties +1 -1
- package/ios/Application/ApplicationEnvironment.swift +4 -6
- package/ios/Application/HeaderRight.swift +17 -1
- package/ios/Application/Localize.swift +4 -73
- package/ios/Application/Navigation/NavigationContainer.swift +18 -25
- package/ios/Application/Navigation/Navigator.swift +29 -91
- package/ios/Application/StackScreen.swift +21 -18
- package/ios/Badge/BadgeRibbon.swift +1 -4
- package/ios/Button/Button.swift +61 -41
- package/ios/Icon/Icon.swift +4 -13
- package/ios/native-kits.podspec +1 -2
- package/ios-demo/MoMoUIKitsDemo.podspec +1 -1
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/ButtonDemo.swift +8 -8
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/CheckboxDemo.swift +2 -3
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/Home.swift +12 -5
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/LocalizeDemo.swift +4 -51
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/NavigationDemo.swift +2 -14
- package/ios-demo/Sources/MoMoUIKitsDemo/SwiftUIDemoBrowserView.swift +1 -3
- package/local.properties +2 -2
- package/package.json +1 -1
- package/.claude/settings.local.json +0 -158
- package/ios/Application/LanguageSource.swift +0 -93
- package/ios/Application/LocalizeModifier.swift +0 -30
- package/ios/Application/Navigation/NavigatorManager.swift +0 -49
- package/ios/Application/Navigation/component/NavigationFooter.swift +0 -90
- package/ios/Layout/GridContext.swift +0 -30
- package/ios/Layout/Item.swift +0 -42
- package/ios/Layout/Section.swift +0 -134
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/IconDemo.swift +0 -104
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/SectionDemo.swift +0 -100
|
@@ -28,69 +28,16 @@ public final class Localize: ObservableObject {
|
|
|
28
28
|
public static let VI = "vi"
|
|
29
29
|
public static let EN = "en"
|
|
30
30
|
|
|
31
|
-
// AI-GENERATED START: follow the host app language and publish dictionary changes
|
|
32
|
-
/// App-wide instance bound to the host language; the environment default uses it too,
|
|
33
|
-
/// so every screen agrees on one language.
|
|
34
|
-
public static let shared = Localize()
|
|
35
|
-
// AI-GENERATED END: follow the host app language and publish dictionary changes
|
|
36
|
-
|
|
37
31
|
private var assets: LocalizationObject
|
|
38
32
|
@Published public private(set) var currentLanguage: String = Localize.VI
|
|
39
33
|
|
|
40
|
-
|
|
41
|
-
/// Bumped by `addTranslations` — `assets` is not published, so without this the dictionary
|
|
42
|
-
/// can change without any consumer re-rendering.
|
|
43
|
-
@Published public private(set) var revision: Int = 0
|
|
44
|
-
|
|
45
|
-
private let languageSource: LanguageSource?
|
|
46
|
-
|
|
47
|
-
/// `NavigationContainer` uses this to skip its legacy one-shot `MaxApi` pull.
|
|
48
|
-
var observesAppLanguage: Bool { languageSource != nil }
|
|
49
|
-
|
|
50
|
-
/// Pass `languageSource: nil` to opt out and keep the old "starts at vi" behavior.
|
|
51
|
-
public init(
|
|
52
|
-
_ translations: LocalizationObject = LocalizationObject(),
|
|
53
|
-
languageSource: LanguageSource? = UserDefaultsLanguageSource()
|
|
54
|
-
) {
|
|
34
|
+
public init(_ translations: LocalizationObject = LocalizationObject()) {
|
|
55
35
|
self.assets = Localize.merge(base: Localize.defaultLanguage, override: translations)
|
|
56
|
-
self.languageSource = languageSource
|
|
57
|
-
guard let languageSource else { return }
|
|
58
|
-
// Seed synchronously so the first frame is already correct (no vi -> en flash).
|
|
59
|
-
currentLanguage = languageSource.currentLanguage
|
|
60
|
-
languageSource.startObserving { [weak self] language in self?.apply(language) }
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
deinit {
|
|
64
|
-
languageSource?.stopObserving()
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/// Host values are sometimes legacy JSON-quoted (`"\"vi\""`); anything that is not `en` is `vi`.
|
|
68
|
-
public static func normalize(_ raw: String?) -> String {
|
|
69
|
-
let cleaned = (raw ?? "")
|
|
70
|
-
.replacingOccurrences(of: "\"", with: "")
|
|
71
|
-
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
72
|
-
.lowercased()
|
|
73
|
-
return cleaned == Localize.EN ? Localize.EN : Localize.VI
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
private func apply(_ language: String) {
|
|
77
|
-
// KVO and didChangeNotification both fire for one write; publish once, on main.
|
|
78
|
-
onMain {
|
|
79
|
-
guard self.currentLanguage != language else { return }
|
|
80
|
-
self.currentLanguage = language
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/// Synchronous when already on main — an async hop would defer the switch by a frame.
|
|
85
|
-
private func onMain(_ work: @escaping () -> Void) {
|
|
86
|
-
if Thread.isMainThread { work() } else { DispatchQueue.main.async(execute: work) }
|
|
87
36
|
}
|
|
88
37
|
|
|
89
|
-
/// A local override: the next host language change supersedes it.
|
|
90
38
|
public func changeLanguage(_ language: String?) {
|
|
91
|
-
|
|
39
|
+
currentLanguage = (language == Localize.EN) ? Localize.EN : Localize.VI
|
|
92
40
|
}
|
|
93
|
-
// AI-GENERATED END: follow the host app language and publish dictionary changes
|
|
94
41
|
|
|
95
42
|
public func translate(_ key: String) -> String {
|
|
96
43
|
let dictionary = currentLanguage == Localize.EN ? assets.en : assets.vi
|
|
@@ -111,9 +58,6 @@ public final class Localize: ObservableObject {
|
|
|
111
58
|
/// Existing keys win (mirrors Compose: `merge(translations, assets)`).
|
|
112
59
|
public func addTranslations(_ translations: LocalizationObject) {
|
|
113
60
|
assets = Localize.merge(base: translations, override: assets)
|
|
114
|
-
// AI-GENERATED START: publish dictionary changes so consumers re-render
|
|
115
|
-
onMain { self.revision &+= 1 }
|
|
116
|
-
// AI-GENERATED END: publish dictionary changes so consumers re-render
|
|
117
61
|
}
|
|
118
62
|
|
|
119
63
|
private static func merge(base: LocalizationObject, override: LocalizationObject) -> LocalizationObject {
|
|
@@ -339,22 +283,14 @@ public final class Localize: ObservableObject {
|
|
|
339
283
|
private struct LocalizeBox: Equatable {
|
|
340
284
|
let localize: Localize
|
|
341
285
|
let language: String
|
|
342
|
-
// AI-GENERATED START: invalidate consumers when the dictionary changes, not just the language
|
|
343
|
-
let revision: Int
|
|
344
|
-
// AI-GENERATED END: invalidate consumers when the dictionary changes, not just the language
|
|
345
286
|
|
|
346
287
|
init(_ localize: Localize) {
|
|
347
288
|
self.localize = localize
|
|
348
289
|
self.language = localize.currentLanguage
|
|
349
|
-
// AI-GENERATED START: invalidate consumers when the dictionary changes, not just the language
|
|
350
|
-
self.revision = localize.revision
|
|
351
|
-
// AI-GENERATED END: invalidate consumers when the dictionary changes, not just the language
|
|
352
290
|
}
|
|
353
291
|
|
|
354
292
|
static func == (lhs: LocalizeBox, rhs: LocalizeBox) -> Bool {
|
|
355
|
-
|
|
356
|
-
lhs.localize === rhs.localize && lhs.language == rhs.language && lhs.revision == rhs.revision
|
|
357
|
-
// AI-GENERATED END: invalidate consumers when the dictionary changes, not just the language
|
|
293
|
+
lhs.localize === rhs.localize && lhs.language == rhs.language
|
|
358
294
|
}
|
|
359
295
|
}
|
|
360
296
|
|
|
@@ -362,12 +298,7 @@ private struct LocalizeKey: EnvironmentKey {
|
|
|
362
298
|
/// A default-constructed `Localize` already carries the full kit dictionary, so a
|
|
363
299
|
/// consumer that never injects one renders untranslated-but-correct text instead of
|
|
364
300
|
/// trapping the way `@EnvironmentObject` does.
|
|
365
|
-
|
|
366
|
-
/// `defaultValue` is a `static let`, so this box is never rebuilt: without a provider the
|
|
367
|
-
/// language is right on first render but does not live-update. Wrap the root in
|
|
368
|
-
/// `.momoLocalize()` (or use `NavigationContainer`) to get updates.
|
|
369
|
-
static let defaultValue = LocalizeBox(Localize.shared)
|
|
370
|
-
// AI-GENERATED END: default to the app-language-observing shared instance
|
|
301
|
+
static let defaultValue = LocalizeBox(Localize())
|
|
371
302
|
}
|
|
372
303
|
|
|
373
304
|
public extension EnvironmentValues {
|
|
@@ -46,9 +46,7 @@ public struct NavigationContainer<Initial: View>: View {
|
|
|
46
46
|
maxApi: maxApi
|
|
47
47
|
)
|
|
48
48
|
)
|
|
49
|
-
|
|
50
|
-
_localize = StateObject(wrappedValue: localize ?? Localize.shared)
|
|
51
|
-
// AI-GENERATED END: default to the app-language-observing shared Localize
|
|
49
|
+
_localize = StateObject(wrappedValue: localize ?? Localize())
|
|
52
50
|
self.composeApi = composeApi
|
|
53
51
|
self.maxApi = maxApi
|
|
54
52
|
self.setNavigator = setNavigator
|
|
@@ -64,17 +62,15 @@ public struct NavigationContainer<Initial: View>: View {
|
|
|
64
62
|
.environmentObject(navigator)
|
|
65
63
|
.environmentObject(applicationEnvironment)
|
|
66
64
|
.environment(\.applicationEnvironment, applicationEnvironment)
|
|
67
|
-
|
|
68
|
-
.
|
|
69
|
-
// AI-GENERATED END: route Localize injection through the observing modifier
|
|
65
|
+
.environmentObject(localize)
|
|
66
|
+
.environment(\.localize, localize)
|
|
70
67
|
.fullScreenCover(item: $navigator.presented) { route in
|
|
71
68
|
screenView(forDialog: route)
|
|
72
69
|
.environmentObject(navigator)
|
|
73
70
|
.environmentObject(applicationEnvironment)
|
|
74
71
|
.environment(\.applicationEnvironment, applicationEnvironment)
|
|
75
|
-
|
|
76
|
-
.
|
|
77
|
-
// AI-GENERATED END: route Localize injection through the observing modifier
|
|
72
|
+
.environmentObject(localize)
|
|
73
|
+
.environment(\.localize, localize)
|
|
78
74
|
}
|
|
79
75
|
.overlay {
|
|
80
76
|
if let item = navigator.overplay {
|
|
@@ -82,9 +78,8 @@ public struct NavigationContainer<Initial: View>: View {
|
|
|
82
78
|
.environmentObject(navigator)
|
|
83
79
|
.environmentObject(applicationEnvironment)
|
|
84
80
|
.environment(\.applicationEnvironment, applicationEnvironment)
|
|
85
|
-
|
|
86
|
-
.
|
|
87
|
-
// AI-GENERATED END: route Localize injection through the observing modifier
|
|
81
|
+
.environmentObject(localize)
|
|
82
|
+
.environment(\.localize, localize)
|
|
88
83
|
}
|
|
89
84
|
}
|
|
90
85
|
.onAppear {
|
|
@@ -92,21 +87,13 @@ public struct NavigationContainer<Initial: View>: View {
|
|
|
92
87
|
navigator.composeApi = composeApi
|
|
93
88
|
}
|
|
94
89
|
setNavigator?(navigator)
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
NavigatorManager.shared.push(navigator)
|
|
98
|
-
// AI-GENERATED START: demote the MaxApi language pull to a seed of last resort
|
|
99
|
-
// Only a seed: a Localize with a language source already tracks the host in both
|
|
100
|
-
// directions, so asking maxApi would just race it. `changeLanguage` normalizes the
|
|
101
|
-
// raw response and hops to main itself.
|
|
102
|
-
guard !localize.observesAppLanguage else { return }
|
|
90
|
+
// Mirrors Compose's LaunchedEffect(maxApi, resolvedLocalize): host-reported
|
|
91
|
+
// language wins over the default "vi" only when it resolves to English.
|
|
103
92
|
maxApi?.getLanguage { data in
|
|
104
|
-
|
|
93
|
+
if parseLanguage(data) == Localize.EN {
|
|
94
|
+
localize.changeLanguage(Localize.EN)
|
|
95
|
+
}
|
|
105
96
|
}
|
|
106
|
-
// AI-GENERATED END: demote the MaxApi language pull to a seed of last resort
|
|
107
|
-
}
|
|
108
|
-
.onDisappear {
|
|
109
|
-
NavigatorManager.shared.remove(navigator)
|
|
110
97
|
}
|
|
111
98
|
}
|
|
112
99
|
|
|
@@ -208,3 +195,9 @@ private func safeAreaBottomInset() -> CGFloat {
|
|
|
208
195
|
.compactMap { ($0 as? UIWindowScene)?.keyWindow?.safeAreaInsets.bottom }
|
|
209
196
|
.first ?? 0
|
|
210
197
|
}
|
|
198
|
+
|
|
199
|
+
private func parseLanguage(_ raw: [String: Any?]?) -> String? {
|
|
200
|
+
guard let response = (raw?["response"] as? String)?.trimmingCharacters(in: .whitespaces).lowercased(),
|
|
201
|
+
!response.isEmpty else { return nil }
|
|
202
|
+
return response.hasPrefix(Localize.EN) ? Localize.EN : Localize.VI
|
|
203
|
+
}
|
|
@@ -89,11 +89,7 @@ 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] = []
|
|
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
|
-
}
|
|
92
|
+
@Published public var path: [DynamicScreenRoute] = []
|
|
97
93
|
@Published public var presented: DynamicDialogRoute?
|
|
98
94
|
@Published public var overplay: OverplayItem?
|
|
99
95
|
|
|
@@ -151,7 +147,8 @@ public final class Navigator: ObservableObject {
|
|
|
151
147
|
|
|
152
148
|
public func replace<V: View>(@ViewBuilder _ content: @escaping () -> V, options: NavigationOptions? = nil) {
|
|
153
149
|
guard !path.isEmpty else { return }
|
|
154
|
-
path.removeLast()
|
|
150
|
+
let removed = path.removeLast()
|
|
151
|
+
registry.unregister(id: removed.id)
|
|
155
152
|
push(content, options: options)
|
|
156
153
|
}
|
|
157
154
|
|
|
@@ -161,12 +158,14 @@ public final class Navigator: ObservableObject {
|
|
|
161
158
|
options: NavigationOptions? = nil
|
|
162
159
|
) {
|
|
163
160
|
guard !path.isEmpty else { return }
|
|
164
|
-
path.removeLast()
|
|
161
|
+
let removed = path.removeLast()
|
|
162
|
+
registry.unregister(id: removed.id)
|
|
165
163
|
push(screenName: screenName, content, options: options)
|
|
166
164
|
}
|
|
167
165
|
|
|
168
166
|
public func reset<V: View>(@ViewBuilder _ content: @escaping () -> V, options: NavigationOptions? = nil) {
|
|
169
|
-
|
|
167
|
+
for route in path { registry.unregister(id: route.id) }
|
|
168
|
+
registry.unregister(id: rootRoute.id)
|
|
170
169
|
path.removeAll()
|
|
171
170
|
let route = registry.register(
|
|
172
171
|
screenName: options?.screenName ?? "",
|
|
@@ -174,7 +173,6 @@ public final class Navigator: ObservableObject {
|
|
|
174
173
|
options: options
|
|
175
174
|
)
|
|
176
175
|
rootRoute = route
|
|
177
|
-
disposeScreens([previousRoot])
|
|
178
176
|
}
|
|
179
177
|
|
|
180
178
|
public func reset<V: View>(
|
|
@@ -182,7 +180,8 @@ public final class Navigator: ObservableObject {
|
|
|
182
180
|
@ViewBuilder _ content: @escaping () -> V,
|
|
183
181
|
options: NavigationOptions? = nil
|
|
184
182
|
) {
|
|
185
|
-
|
|
183
|
+
for route in path { registry.unregister(id: route.id) }
|
|
184
|
+
registry.unregister(id: rootRoute.id)
|
|
186
185
|
path.removeAll()
|
|
187
186
|
let route = registry.register(
|
|
188
187
|
screenName: screenName,
|
|
@@ -190,50 +189,26 @@ public final class Navigator: ObservableObject {
|
|
|
190
189
|
options: options
|
|
191
190
|
)
|
|
192
191
|
rootRoute = route
|
|
193
|
-
disposeScreens([previousRoot])
|
|
194
192
|
}
|
|
195
193
|
|
|
196
194
|
public func pop(_ count: Int = 1, callback: (() -> Void)? = nil) {
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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)
|
|
195
|
+
var remaining = count
|
|
196
|
+
while remaining > 0 {
|
|
197
|
+
if overplay?.type == .snackBar {
|
|
198
|
+
// Mirrors Kotlin: a snackbar doesn't block back-navigation, so popping
|
|
199
|
+
// while one is showing pops the screen underneath and clears the
|
|
200
|
+
// snackbar immediately, without playing its dismiss animation.
|
|
201
|
+
overplayDismissHandler = nil
|
|
202
|
+
overplay = nil
|
|
203
|
+
if !popScreen() { break }
|
|
204
|
+
} else if overplay != nil {
|
|
205
|
+
hideOverplay()
|
|
206
|
+
} else if !popScreen() {
|
|
207
|
+
break
|
|
229
208
|
}
|
|
230
|
-
|
|
231
|
-
guard popScreen() else {
|
|
232
|
-
callback?()
|
|
233
|
-
return
|
|
234
|
-
}
|
|
235
|
-
popStep(remaining: remaining - 1, callback: callback)
|
|
209
|
+
remaining -= 1
|
|
236
210
|
}
|
|
211
|
+
callback?()
|
|
237
212
|
}
|
|
238
213
|
|
|
239
214
|
/// Pops the topmost presented screen or stack entry. Returns `false` (and requests
|
|
@@ -243,10 +218,11 @@ public final class Navigator: ObservableObject {
|
|
|
243
218
|
if presented != nil {
|
|
244
219
|
let dismissedId = presented?.id
|
|
245
220
|
presented = nil
|
|
246
|
-
if let id = dismissedId {
|
|
221
|
+
if let id = dismissedId { registry.unregister(id: id) }
|
|
247
222
|
return true
|
|
248
223
|
} else if !path.isEmpty {
|
|
249
|
-
path.removeLast()
|
|
224
|
+
let removed = path.removeLast()
|
|
225
|
+
registry.unregister(id: removed.id)
|
|
250
226
|
return true
|
|
251
227
|
} else {
|
|
252
228
|
composeApi?.requestCallback(funcName: "dismiss", params: nil, onResponse: nil)
|
|
@@ -255,18 +231,12 @@ public final class Navigator: ObservableObject {
|
|
|
255
231
|
}
|
|
256
232
|
|
|
257
233
|
public func popToRoot() {
|
|
234
|
+
for route in path { registry.unregister(id: route.id) }
|
|
258
235
|
path.removeAll()
|
|
259
236
|
}
|
|
260
237
|
|
|
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
|
-
|
|
268
238
|
public func onBackSafe(callback: (() -> Void)? = nil) {
|
|
269
|
-
let latest =
|
|
239
|
+
let latest = registry.latest()
|
|
270
240
|
if let onBack = latest?.options?.onBackHandler {
|
|
271
241
|
onBack()
|
|
272
242
|
return
|
|
@@ -412,36 +382,4 @@ public final class Navigator: ObservableObject {
|
|
|
412
382
|
public func hideSnackBar() {
|
|
413
383
|
if overplay?.type == .snackBar { hideOverplay() }
|
|
414
384
|
}
|
|
415
|
-
|
|
416
|
-
// MARK: Screen disposal
|
|
417
|
-
|
|
418
|
-
/// Matches the `delay(300)` Compose waits in `dismissScreen` before unregistering,
|
|
419
|
-
/// with a little headroom for the pop transition.
|
|
420
|
-
private static let screenDisposeDelay: TimeInterval = 0.35
|
|
421
|
-
|
|
422
|
-
/// Longest overplay exit animation (the bottom sheet's), so a queued pop step runs
|
|
423
|
-
/// after the component has taken itself down.
|
|
424
|
-
private static let overplayDismissDelay: TimeInterval = 0.3
|
|
425
|
-
|
|
426
|
-
private func disposeScreens(droppedFrom previous: [DynamicScreenRoute]) {
|
|
427
|
-
let live = Set(path.map(\.id))
|
|
428
|
-
disposeScreens(previous.map(\.id).filter { !live.contains($0) })
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
/// Screens are released only once the pop transition has played out: the outgoing
|
|
432
|
-
/// view is still on screen while it animates and the registry is what renders it —
|
|
433
|
-
/// dropping it right away blanks the screen mid-transition. Liveness is re-checked
|
|
434
|
-
/// when the timer fires, so a route pushed back before then keeps its screen.
|
|
435
|
-
private func disposeScreens(_ ids: [Int]) {
|
|
436
|
-
guard !ids.isEmpty else { return }
|
|
437
|
-
DispatchQueue.main.asyncAfter(deadline: .now() + Self.screenDisposeDelay) { [weak self] in
|
|
438
|
-
guard let self else { return }
|
|
439
|
-
var live = Set(self.path.map(\.id))
|
|
440
|
-
live.insert(self.rootRoute.id)
|
|
441
|
-
if let presented = self.presented { live.insert(presented.id) }
|
|
442
|
-
for id in ids where !live.contains(id) {
|
|
443
|
-
self.registry.unregister(id: id)
|
|
444
|
-
}
|
|
445
|
-
}
|
|
446
|
-
}
|
|
447
385
|
}
|
|
@@ -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 (`FooterContent`) — 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
|
//
|
|
@@ -165,13 +165,12 @@ public struct StackScreen<Content: View>: View {
|
|
|
165
165
|
|
|
166
166
|
FloatingContent(
|
|
167
167
|
fabProps: fabProps,
|
|
168
|
-
|
|
169
|
-
hasFooter: footer != nil,
|
|
168
|
+
keyboardOffset: 0,
|
|
170
169
|
scrollOffset: scrollOffset
|
|
171
170
|
)
|
|
172
171
|
}
|
|
173
172
|
|
|
174
|
-
|
|
173
|
+
FooterContent(footer: footer, keyboardSize: keyboardSize, bottomInset: bottomInset)
|
|
175
174
|
}
|
|
176
175
|
|
|
177
176
|
// Animated search bar overlay (mirrors Compose's SearchAnimated layer).
|
|
@@ -445,29 +444,17 @@ private struct SearchAnimatedHeader: View {
|
|
|
445
444
|
/// Mirrors Compose's `FloatingContent`: no FAB props means nothing renders.
|
|
446
445
|
private struct FloatingContent: View {
|
|
447
446
|
let fabProps: FabProps?
|
|
448
|
-
let
|
|
449
|
-
/// With a footer the FAB is anchored to the footer's *layout* top, which does not move
|
|
450
|
-
/// when the band offsets itself over the keyboard — so it rides the same offset.
|
|
451
|
-
let hasFooter: Bool
|
|
447
|
+
let keyboardOffset: CGFloat
|
|
452
448
|
let scrollOffset: CGFloat
|
|
453
449
|
|
|
454
|
-
/// Compose keeps the FAB `Spacing.M + AppNavigationBar` above the bottom of the screen
|
|
455
|
-
/// and lifts it with `imePadding()`; here it sits `Spacing.M` above the safe area and
|
|
456
|
-
/// nothing lifts it, so the difference is applied as an offset (see NavigationFooter).
|
|
457
|
-
private var verticalOffset: CGFloat {
|
|
458
|
-
if hasFooter { return NavigationFooter.verticalOffset(keyboardSize: keyboardSize) }
|
|
459
|
-
return NavigationFooter.safeAreaBottom - NavigationFooter.navigationBarInset - keyboardSize
|
|
460
|
-
}
|
|
461
|
-
|
|
462
450
|
var body: some View {
|
|
463
451
|
if let fabProps = fabProps {
|
|
464
452
|
FloatingButton(
|
|
465
453
|
props: fabProps,
|
|
466
|
-
keyboardOffset:
|
|
454
|
+
keyboardOffset: keyboardOffset,
|
|
467
455
|
scrollOffset: scrollOffset
|
|
468
456
|
)
|
|
469
457
|
.padding(.horizontal, Spacing.M)
|
|
470
|
-
.offset(y: verticalOffset)
|
|
471
458
|
.ignoresSafeArea(.keyboard, edges: .bottom)
|
|
472
459
|
}
|
|
473
460
|
}
|
|
@@ -496,3 +483,19 @@ private struct HideKeyboardOnTap: ViewModifier {
|
|
|
496
483
|
}
|
|
497
484
|
}
|
|
498
485
|
}
|
|
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
|
+
}
|
|
@@ -174,7 +174,6 @@ public struct BadgeRibbon: View {
|
|
|
174
174
|
)
|
|
175
175
|
.frame(width: RibbonConstants.headTailWidth, height: RibbonConstants.headTailHeight)
|
|
176
176
|
.rotationEffect(.degrees(180))
|
|
177
|
-
.offset(x: 1)
|
|
178
177
|
.clipped()
|
|
179
178
|
}
|
|
180
179
|
|
|
@@ -184,7 +183,6 @@ public struct BadgeRibbon: View {
|
|
|
184
183
|
placeholder: AnyView(Color.clear)
|
|
185
184
|
)
|
|
186
185
|
.frame(width: RibbonConstants.headTailWidth, height: RibbonConstants.headTailHeight)
|
|
187
|
-
.offset(x: 1)
|
|
188
186
|
.clipped()
|
|
189
187
|
}
|
|
190
188
|
|
|
@@ -194,7 +192,6 @@ public struct BadgeRibbon: View {
|
|
|
194
192
|
placeholder: AnyView(Color.clear)
|
|
195
193
|
)
|
|
196
194
|
.frame(width: RibbonConstants.skewTailWidth, height: RibbonConstants.skewTailHeight)
|
|
197
|
-
.offset(x: -2)
|
|
198
195
|
.clipped()
|
|
199
196
|
}
|
|
200
197
|
}
|
|
@@ -284,7 +281,7 @@ private struct RibbonConstants {
|
|
|
284
281
|
static let ribbonHeight: CGFloat = 20
|
|
285
282
|
static let roundHeight: CGFloat = 16
|
|
286
283
|
static let skewBodyHeight: CGFloat = 16
|
|
287
|
-
static let roundRightRadius: CGFloat =
|
|
284
|
+
static let roundRightRadius: CGFloat = 12
|
|
288
285
|
static let roundPaddingEnd: CGFloat = 6
|
|
289
286
|
static let skewTailWidth: CGFloat = 8
|
|
290
287
|
static let skewTailHeight: CGFloat = 16
|
package/ios/Button/Button.swift
CHANGED
|
@@ -133,19 +133,19 @@ public struct Button: View {
|
|
|
133
133
|
var action: () -> Void
|
|
134
134
|
var type: ButtonType
|
|
135
135
|
var size: ButtonSize
|
|
136
|
-
var iconLeft:
|
|
137
|
-
var iconRight:
|
|
136
|
+
var iconLeft: AnyView?
|
|
137
|
+
var iconRight: AnyView?
|
|
138
138
|
var isFull: Bool
|
|
139
139
|
var loading: Bool
|
|
140
140
|
var useTintColor: Bool
|
|
141
141
|
|
|
142
142
|
public init(
|
|
143
|
-
title: String = "
|
|
143
|
+
title: String = "",
|
|
144
144
|
action: @escaping () -> Void,
|
|
145
145
|
type: ButtonType = .primary,
|
|
146
146
|
size: ButtonSize = .large,
|
|
147
|
-
iconLeft:
|
|
148
|
-
iconRight:
|
|
147
|
+
iconLeft: AnyView? = nil,
|
|
148
|
+
iconRight: AnyView? = nil,
|
|
149
149
|
isFull: Bool = true,
|
|
150
150
|
loading: Bool = false,
|
|
151
151
|
useTintColor: Bool = true
|
|
@@ -161,9 +161,38 @@ public struct Button: View {
|
|
|
161
161
|
self.useTintColor = useTintColor
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
164
|
+
/// Mirrors Kotlin's `Button(iconLeft: String, iconRight: String, ...)`, which resolves
|
|
165
|
+
/// icons by design-system source name through the shared `Icon` component rather than
|
|
166
|
+
/// a caller-supplied view. `iconLeft` is required here (unlike Kotlin's `= ""` default)
|
|
167
|
+
/// solely to keep this initializer unambiguous alongside the `AnyView`-based one above —
|
|
168
|
+
/// pass `""` explicitly for an icon-right-only button.
|
|
169
|
+
public init(
|
|
170
|
+
title: String = "Button",
|
|
171
|
+
action: @escaping () -> Void,
|
|
172
|
+
type: ButtonType = .primary,
|
|
173
|
+
size: ButtonSize = .large,
|
|
174
|
+
iconLeft: String,
|
|
175
|
+
iconRight: String = "",
|
|
176
|
+
isFull: Bool = true,
|
|
177
|
+
loading: Bool = false,
|
|
178
|
+
useTintColor: Bool = true
|
|
179
|
+
) {
|
|
180
|
+
self.init(
|
|
181
|
+
title: title,
|
|
182
|
+
action: action,
|
|
183
|
+
type: type,
|
|
184
|
+
size: size,
|
|
185
|
+
iconLeft: iconLeft.isEmpty ? nil : AnyView(Icon(source: iconLeft)),
|
|
186
|
+
iconRight: iconRight.isEmpty ? nil : AnyView(Icon(source: iconRight)),
|
|
187
|
+
isFull: isFull,
|
|
188
|
+
loading: loading,
|
|
189
|
+
useTintColor: useTintColor
|
|
190
|
+
)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private func shouldLoadingOnLeft(_ left: AnyView?, _ right: AnyView?) -> Bool {
|
|
194
|
+
let hasLeft = left != nil
|
|
195
|
+
let hasRight = right != nil
|
|
167
196
|
|
|
168
197
|
switch (hasLeft, hasRight) {
|
|
169
198
|
case (false, false): return true
|
|
@@ -188,13 +217,27 @@ public struct Button: View {
|
|
|
188
217
|
}
|
|
189
218
|
}) {
|
|
190
219
|
HStack(spacing: size.iconSpacing) {
|
|
191
|
-
|
|
220
|
+
if loading && loadingOnLeft {
|
|
221
|
+
LottieView(name: "lottie_circle_loader", loopMode: .loop)
|
|
222
|
+
.frame(width: size.iconSize, height: size.iconSize)
|
|
223
|
+
.colorMultiply(fg)
|
|
224
|
+
|
|
225
|
+
} else if let iconLeft = iconLeft {
|
|
226
|
+
tintedIcon(iconLeft, color: fg)
|
|
227
|
+
}
|
|
192
228
|
|
|
193
229
|
MomoText(title, typography: size.typographyStyle, color: fg)
|
|
194
230
|
.lineLimit(1)
|
|
195
231
|
.truncationMode(.tail)
|
|
196
232
|
|
|
197
|
-
|
|
233
|
+
if loading && !loadingOnLeft {
|
|
234
|
+
LottieView(name: "lottie_circle_loader", loopMode: .loop)
|
|
235
|
+
.frame(width: size.iconSize, height: size.iconSize)
|
|
236
|
+
.colorMultiply(fg)
|
|
237
|
+
|
|
238
|
+
} else if let iconRight = iconRight {
|
|
239
|
+
tintedIcon(iconRight, color: fg)
|
|
240
|
+
}
|
|
198
241
|
}
|
|
199
242
|
.frame(maxWidth: isFull ? .infinity : nil)
|
|
200
243
|
.padding(.horizontal, size.padding)
|
|
@@ -206,24 +249,20 @@ public struct Button: View {
|
|
|
206
249
|
border: border,
|
|
207
250
|
borderWidth: borderWidth,
|
|
208
251
|
radius: size.radius,
|
|
209
|
-
height: size.height
|
|
210
|
-
isFull: isFull
|
|
252
|
+
height: size.height
|
|
211
253
|
))
|
|
212
254
|
.disabled(!isEnabled)
|
|
213
255
|
}
|
|
214
256
|
|
|
215
|
-
/// Mirrors Kotlin `RenderIcon`: the loader replaces the icon slot entirely, and
|
|
216
|
-
/// `useTintColor == false` leaves the icon at its own colors.
|
|
217
257
|
@ViewBuilder
|
|
218
|
-
private func
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
258
|
+
private func tintedIcon(_ icon: AnyView, color: Color) -> some View {
|
|
259
|
+
if useTintColor {
|
|
260
|
+
icon
|
|
261
|
+
.frame(width: size.iconSize, height: size.iconSize)
|
|
262
|
+
.colorMultiply(color)
|
|
263
|
+
} else {
|
|
264
|
+
icon
|
|
223
265
|
.frame(width: size.iconSize, height: size.iconSize)
|
|
224
|
-
.colorMultiply(color ?? Colors.black01)
|
|
225
|
-
} else if !icon.isEmpty {
|
|
226
|
-
Icon(source: icon, size: size.iconSize, color: color)
|
|
227
266
|
}
|
|
228
267
|
}
|
|
229
268
|
}
|
|
@@ -238,13 +277,11 @@ private struct ButtonPressFeedbackStyle: ButtonStyle {
|
|
|
238
277
|
let borderWidth: CGFloat
|
|
239
278
|
let radius: CGFloat
|
|
240
279
|
let height: CGFloat
|
|
241
|
-
let isFull: Bool
|
|
242
280
|
|
|
243
281
|
func makeBody(configuration: Configuration) -> some View {
|
|
244
282
|
let pressed = configuration.isPressed && isEnabled
|
|
245
283
|
|
|
246
284
|
configuration.label
|
|
247
|
-
.modifier(HugContentOnLegacyOS(isFull: isFull))
|
|
248
285
|
.opacity(pressed ? 0.5 : 1)
|
|
249
286
|
.background(
|
|
250
287
|
RoundedRectangle(cornerRadius: radius)
|
|
@@ -260,20 +297,3 @@ private struct ButtonPressFeedbackStyle: ButtonStyle {
|
|
|
260
297
|
.animation(.easeInOut(duration: 0.1), value: pressed)
|
|
261
298
|
}
|
|
262
299
|
}
|
|
263
|
-
|
|
264
|
-
/// `ButtonStyleConfiguration.Label` is greedy on iOS 16 and earlier — it accepts
|
|
265
|
-
/// the full proposed width, so the background drawn in `makeBody` stretches past
|
|
266
|
-
/// the content even when `isFull == false`. iOS 17 fixed the label to hug its
|
|
267
|
-
/// content, so only legacy OSes need the `fixedSize` clamp (kept off on 17+ to
|
|
268
|
-
/// preserve tail truncation of long titles).
|
|
269
|
-
private struct HugContentOnLegacyOS: ViewModifier {
|
|
270
|
-
let isFull: Bool
|
|
271
|
-
|
|
272
|
-
func body(content: Content) -> some View {
|
|
273
|
-
if #available(iOS 17.0, *) {
|
|
274
|
-
content
|
|
275
|
-
} else {
|
|
276
|
-
content.fixedSize(horizontal: !isFull, vertical: false)
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
}
|