@momo-kits/native-kits 0.167.1-duo.21 → 0.167.1-duo.22-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/build.gradle.kts +11 -0
- package/compose/build.gradle.kts +204 -0
- package/compose/build.gradle.kts.backup +204 -0
- package/compose/compose.podspec +47 -0
- package/compose/src/androidMain/kotlin/vn/momo/kits/const/HostFont.android.kt +19 -0
- package/compose/src/androidMain/kotlin/vn/momo/kits/navigation/ScrollToTop.android.kt +6 -0
- package/compose/src/androidMain/kotlin/vn/momo/kits/platform/BackGesture.android.kt +12 -0
- package/compose/src/androidMain/kotlin/vn/momo/kits/platform/OsFontScale.android.kt +7 -0
- package/compose/src/androidMain/kotlin/vn/momo/kits/platform/Platform.android.kt +153 -0
- package/compose/src/androidMain/kotlin/vn/momo/kits/platform/ReservedRegions.android.kt +6 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/application/Context.kt +124 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/application/LiteScreen.kt +951 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/application/Localize.kt +269 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/application/WidgetContainer.kt +56 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Avatar.kt +157 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Badge.kt +81 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/BadgeDot.kt +32 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/BadgeRibbon.kt +338 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/BaselineView.kt +175 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Button.kt +357 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Carousel.kt +115 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/CheckBox.kt +89 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Chip.kt +128 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Collapse.kt +209 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/CupertinoOverscroll.kt +542 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Divider.kt +49 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Icon.kt +86 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/IconButton.kt +125 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Image.kt +231 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Information.kt +189 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Input.kt +478 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputDropDown.kt +114 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputMoney.kt +265 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputOTP.kt +235 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputPhoneNumber.kt +223 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputSearch.kt +265 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputTextArea.kt +185 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/LazyColumnWithBouncing.kt +343 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Loader.kt +108 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/PaginationDot.kt +56 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/PaginationNumber.kt +41 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/PaginationScroll.kt +84 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/PaginationWhiteDot.kt +40 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/PopupNotify.kt +361 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/PopupPromotion.kt +108 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/ProgressInfo.kt +308 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Radio.kt +59 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Rating.kt +87 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/ScaleSizeScope.kt +21 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Skeleton.kt +90 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Slider.kt +330 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Stepper.kt +220 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Steps.kt +473 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/SuggestAction.kt +122 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Swipe.kt +207 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Switch.kt +92 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/TabView.kt +560 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Tag.kt +86 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Text.kt +125 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Title.kt +207 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Tooltip.kt +501 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/TrustBanner.kt +176 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Uploader.kt +182 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/DateTimePicker.kt +193 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/DateTimePickerTypes.kt +29 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/DateTimePickerUtils.kt +236 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/WheelPicker.kt +180 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/const/Colors.kt +325 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/const/HostFont.kt +20 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/const/KitDesignSystem.kt +18 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/const/KitFontScale.kt +22 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/const/Radius.kt +12 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/const/Spacing.kt +16 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/const/Theme.kt +194 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/const/Typography.kt +283 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/layout/Card.kt +54 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/layout/Item.kt +50 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/layout/ScreenLayout.kt +309 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/layout/Section.kt +41 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/AutomationId.kt +52 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/Clickable.kt +66 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/Conditional.kt +11 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/DeprecatedModifier.kt +104 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/Shadow.kt +48 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/Size.kt +51 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/BottomSheet.kt +338 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/HostBackGestureBridge.kt +58 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/ModalScreen.kt +137 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigation.kt +116 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/NavigationContainer.kt +205 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigator.kt +399 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/ScrollToTop.kt +8 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/StackScreen.kt +727 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/StackTransition.kt +148 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/SwipeBackHaptic.kt +35 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTab.kt +218 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTabBar.kt +231 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/CurvedContainer.kt +86 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/DuoBarCompression.kt +98 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/DuoTabBar.kt +312 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/FloatingButton.kt +169 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/Header.kt +455 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderBackground.kt +103 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderJourney.kt +106 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderLocation.kt +74 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderRight.kt +399 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderTitle.kt +39 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderUser.kt +363 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/SideBar.kt +192 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/SnackBar.kt +133 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/tracking/ScreenTracker.kt +127 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/platform/BackGesture.kt +12 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/platform/ComposeLottieAnimation.kt +91 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/platform/OsFontScale.kt +9 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/platform/Platform.kt +74 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/platform/ReservedRegions.kt +13 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/utils/Icons.kt +1329 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/utils/Resources.kt +55 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/utils/Tracking.kt +15 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/utils/Utils.kt +118 -0
- package/compose/src/iosMain/kotlin/vn/momo/kits/const/HostFont.ios.kt +78 -0
- package/compose/src/iosMain/kotlin/vn/momo/kits/navigation/HostBackGesture.ios.kt +45 -0
- package/compose/src/iosMain/kotlin/vn/momo/kits/navigation/ScrollToTop.ios.kt +32 -0
- package/compose/src/iosMain/kotlin/vn/momo/kits/platform/BackGesture.ios.kt +38 -0
- package/compose/src/iosMain/kotlin/vn/momo/kits/platform/OsFontScale.ios.kt +66 -0
- package/compose/src/iosMain/kotlin/vn/momo/kits/platform/Platform.ios.kt +217 -0
- package/compose/src/iosMain/kotlin/vn/momo/kits/platform/ReservedRegions.ios.kt +94 -0
- package/gradle/libs.versions.toml +69 -0
- package/gradle/wrapper/gradle-wrapper.jar +0 -0
- package/gradle/wrapper/gradle-wrapper.properties +8 -0
- package/gradle.properties +26 -0
- package/gradlew +252 -0
- package/gradlew.bat +94 -0
- package/ios/Application/MaxApi.swift +20 -2
- package/ios/Input/InputSearch.swift +5 -2
- package/ios/Navigation/BottomSheet.swift +58 -37
- package/ios/Navigation/BottomTab/BottomTab.swift +165 -18
- package/ios/Navigation/BottomTab/BottomTabBar.swift +3 -0
- package/ios/Navigation/Component/FloatingButton.swift +29 -51
- package/ios/Navigation/Component/Header.swift +19 -5
- package/ios/Navigation/Component/HeaderRight.swift +48 -19
- package/ios/Navigation/Component/SnackBar.swift +3 -2
- package/ios/Navigation/ModalScreen.swift +2 -1
- package/ios/Navigation/NavigationContainer.swift +3 -2
- package/ios/Navigation/Navigator.swift +30 -16
- package/ios/Navigation/StackScreen.swift +151 -37
- package/ios-demo/README.md +120 -0
- package/package.json +1 -1
- package/settings.gradle.kts +64 -0
|
@@ -191,38 +191,64 @@ struct Toolkit: View {
|
|
|
191
191
|
return json["response"]
|
|
192
192
|
}
|
|
193
193
|
|
|
194
|
+
private typealias MaxApiRequest = (MaxApi, [String: Any?], @escaping ([String: Any?]?) -> Void) -> Void
|
|
195
|
+
|
|
196
|
+
/// Through `composeApi` while the host still passes one, else `maxApi` (Compose `IMaxApi`).
|
|
197
|
+
/// Hosts may answer off the main thread, so the response is handed back on it.
|
|
198
|
+
private func request(
|
|
199
|
+
_ funcName: String,
|
|
200
|
+
_ params: [String: Any],
|
|
201
|
+
maxApi maxApiRequest: MaxApiRequest,
|
|
202
|
+
onResponse: @escaping (Any?) -> Void = { _ in }
|
|
203
|
+
) {
|
|
204
|
+
if let composeApi = environment.composeApi {
|
|
205
|
+
_ = composeApi.request(funcName: funcName, params: params) { response in
|
|
206
|
+
let value = parseResponse(response)
|
|
207
|
+
DispatchQueue.main.async { onResponse(value) }
|
|
208
|
+
}
|
|
209
|
+
} else if let maxApi = environment.maxApi {
|
|
210
|
+
maxApiRequest(maxApi, params.mapValues { $0 as Any? }) { data in
|
|
211
|
+
let value = data?["response"] ?? nil
|
|
212
|
+
DispatchQueue.main.async { onResponse(value) }
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
194
217
|
// MARK: Actions
|
|
195
218
|
|
|
196
219
|
private func loadFavoriteState() {
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
220
|
+
request(
|
|
221
|
+
"isFavoriteApp",
|
|
222
|
+
["code": apiValue(context?.appCode)],
|
|
223
|
+
maxApi: { $0.isFavoriteApp($1, callback: $2) }
|
|
200
224
|
) { response in
|
|
201
|
-
isFavorite =
|
|
225
|
+
isFavorite = response as? Bool == true
|
|
202
226
|
}
|
|
203
227
|
}
|
|
204
228
|
|
|
205
229
|
private func onPressShortcut() {
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
230
|
+
request(
|
|
231
|
+
"onToolAction",
|
|
232
|
+
["item": ["key": "onFavorite"], "context": contextMap],
|
|
233
|
+
maxApi: { $0.onToolAction($1, callback: $2) }
|
|
209
234
|
) { response in
|
|
210
|
-
if let result =
|
|
235
|
+
if let result = response as? [String: Any], result["success"] as? Bool == true {
|
|
211
236
|
isFavorite.toggle()
|
|
212
237
|
}
|
|
213
238
|
}
|
|
214
239
|
}
|
|
215
240
|
|
|
216
241
|
private func onPressMore() {
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
242
|
+
request(
|
|
243
|
+
"showTools",
|
|
244
|
+
[
|
|
220
245
|
"useSystemTools": headerRight.useSystemTools,
|
|
221
246
|
"tools": headerRight.tools.map { $0.toMap() },
|
|
222
247
|
"context": contextMap
|
|
223
|
-
]
|
|
248
|
+
],
|
|
249
|
+
maxApi: { $0.showTools($1, callback: $2) }
|
|
224
250
|
) { response in
|
|
225
|
-
if let tool =
|
|
251
|
+
if let tool = response as? String {
|
|
226
252
|
headerRight.toolCallback?(tool)
|
|
227
253
|
}
|
|
228
254
|
}
|
|
@@ -230,23 +256,26 @@ struct Toolkit: View {
|
|
|
230
256
|
|
|
231
257
|
private func onPressHelpCenter() {
|
|
232
258
|
guard context != nil else { return }
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
259
|
+
request(
|
|
260
|
+
"showHelpCenter",
|
|
261
|
+
[
|
|
236
262
|
"appId": apiValue(context?.appId),
|
|
237
263
|
"code": apiValue(context?.appCode),
|
|
238
264
|
"name": apiValue(context?.appName),
|
|
239
265
|
"icon": apiValue(context?.appIcon),
|
|
240
266
|
"description": apiValue(context?.description)
|
|
241
|
-
]
|
|
242
|
-
|
|
267
|
+
],
|
|
268
|
+
maxApi: { $0.showHelpCenter($1, callback: $2) }
|
|
269
|
+
)
|
|
243
270
|
}
|
|
244
271
|
|
|
245
272
|
private func onPressClose() {
|
|
246
273
|
if headerRight.useCloseIcon {
|
|
247
274
|
environment.requestDismiss()
|
|
275
|
+
} else if let composeApi = environment.composeApi {
|
|
276
|
+
_ = composeApi.request(funcName: "dismissAll", params: nil) { _ in }
|
|
248
277
|
} else {
|
|
249
|
-
|
|
278
|
+
environment.maxApi?.dismissAll(completion: nil)
|
|
250
279
|
}
|
|
251
280
|
}
|
|
252
281
|
|
|
@@ -50,6 +50,7 @@ public struct SnackBar {
|
|
|
50
50
|
/// Self-animating container. Slides the content up on appear, schedules an
|
|
51
51
|
/// auto-dismiss after `duration`, and animates out before removing the overplay.
|
|
52
52
|
struct SnackBarView: View {
|
|
53
|
+
let overplayId: UUID
|
|
53
54
|
let snackBar: SnackBar
|
|
54
55
|
/// Distance from the window bottom to the top of the screen's footer, or the navigation bar
|
|
55
56
|
/// inset without one (Compose's `footerHeight` offset).
|
|
@@ -121,7 +122,7 @@ struct SnackBarView: View {
|
|
|
121
122
|
offsetY = max(contentHeight, 200) + bottomInset
|
|
122
123
|
}
|
|
123
124
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
|
124
|
-
navigator.finishOverplayDismiss()
|
|
125
|
+
navigator.finishOverplayDismiss(id: overplayId)
|
|
125
126
|
}
|
|
126
127
|
}
|
|
127
128
|
|
|
@@ -133,7 +134,7 @@ struct SnackBarView: View {
|
|
|
133
134
|
guard let duration = snackBar.duration else { return }
|
|
134
135
|
let workItem = DispatchWorkItem {
|
|
135
136
|
// Only auto-dismiss if this snack bar is still the active overplay.
|
|
136
|
-
if navigator.overplay?.
|
|
137
|
+
if navigator.overplay?.id == overplayId {
|
|
137
138
|
navigator.hideSnackBar()
|
|
138
139
|
}
|
|
139
140
|
}
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import SwiftUI
|
|
11
11
|
|
|
12
12
|
struct ModalScreen<Content: View>: View {
|
|
13
|
+
let overplayId: UUID
|
|
13
14
|
let barrierDismissible: Bool
|
|
14
15
|
let content: () -> Content
|
|
15
16
|
|
|
@@ -74,7 +75,7 @@ struct ModalScreen<Content: View>: View {
|
|
|
74
75
|
contentOpacity = 0
|
|
75
76
|
}
|
|
76
77
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
|
77
|
-
navigator.finishOverplayDismiss()
|
|
78
|
+
navigator.finishOverplayDismiss(id: overplayId)
|
|
78
79
|
}
|
|
79
80
|
}
|
|
80
81
|
}
|
|
@@ -168,12 +168,13 @@ private struct OverplayHost: View {
|
|
|
168
168
|
// the Navigator before the item is removed (mirrors Compose's overplay).
|
|
169
169
|
switch item.type {
|
|
170
170
|
case .modal:
|
|
171
|
-
ModalScreen(barrierDismissible: item.barrierDismissible) {
|
|
171
|
+
ModalScreen(overplayId: item.id, barrierDismissible: item.barrierDismissible) {
|
|
172
172
|
item.content()
|
|
173
173
|
}
|
|
174
174
|
.id(item.id)
|
|
175
175
|
case .bottomSheet:
|
|
176
176
|
BottomSheetView(
|
|
177
|
+
overplayId: item.id,
|
|
177
178
|
header: item.header ?? .title(),
|
|
178
179
|
isSurface: item.isSurface,
|
|
179
180
|
barrierDismissible: item.barrierDismissible,
|
|
@@ -186,7 +187,7 @@ private struct OverplayHost: View {
|
|
|
186
187
|
.id(item.id)
|
|
187
188
|
case .snackBar:
|
|
188
189
|
if let snackBar = item.snackBar {
|
|
189
|
-
SnackBarView(snackBar: snackBar, bottomInset: navigator.snackBarBottomInset())
|
|
190
|
+
SnackBarView(overplayId: item.id, snackBar: snackBar, bottomInset: navigator.snackBarBottomInset())
|
|
190
191
|
.id(item.id)
|
|
191
192
|
}
|
|
192
193
|
}
|
|
@@ -238,20 +238,19 @@ public final class Navigator: ObservableObject {
|
|
|
238
238
|
// Mirrors Kotlin: a snackbar doesn't block back-navigation, so popping
|
|
239
239
|
// while one is showing pops the screen underneath and clears the
|
|
240
240
|
// snackbar immediately, without playing its dismiss animation.
|
|
241
|
-
|
|
242
|
-
overplay = nil
|
|
241
|
+
replaceOverplay(with: nil)
|
|
243
242
|
guard popScreen() else {
|
|
244
243
|
callback?()
|
|
245
244
|
return
|
|
246
245
|
}
|
|
247
246
|
popStep(remaining: remaining - 1, callback: callback)
|
|
248
|
-
} else if
|
|
247
|
+
} else if let closing = overplay?.id {
|
|
249
248
|
hideOverplay()
|
|
250
249
|
DispatchQueue.main.asyncAfter(deadline: .now() + Self.overplayDismissDelay) { [weak self] in
|
|
251
250
|
guard let self else { return }
|
|
252
251
|
// The component reports back through `finishOverplayDismiss` once its exit
|
|
253
252
|
// animation ends; clear it here if it never did, so the step is not wasted.
|
|
254
|
-
|
|
253
|
+
self.finishOverplayDismiss(id: closing)
|
|
255
254
|
self.popStep(remaining: remaining - 1, callback: callback)
|
|
256
255
|
}
|
|
257
256
|
} else {
|
|
@@ -302,6 +301,11 @@ public final class Navigator: ObservableObject {
|
|
|
302
301
|
}
|
|
303
302
|
|
|
304
303
|
public func onBackSafe(callback: (() -> Void)? = nil) {
|
|
304
|
+
// An open modal or bottom sheet takes the back first (their BackHandler in Compose).
|
|
305
|
+
if let overplay, overplay.type != .snackBar {
|
|
306
|
+
pop(1, callback: callback)
|
|
307
|
+
return
|
|
308
|
+
}
|
|
305
309
|
let latest = currentScreen()
|
|
306
310
|
if let tabBackHandler, tabBackHandler.screenId == -1 || tabBackHandler.screenId == latest?.id {
|
|
307
311
|
tabBackHandler.handler()
|
|
@@ -420,14 +424,13 @@ public final class Navigator: ObservableObject {
|
|
|
420
424
|
barrierDismissible: Bool = true,
|
|
421
425
|
onDismiss: (() -> Void)? = nil
|
|
422
426
|
) {
|
|
423
|
-
|
|
424
|
-
overplay = OverplayItem(
|
|
427
|
+
replaceOverplay(with: OverplayItem(
|
|
425
428
|
type: .modal,
|
|
426
429
|
content: { AnyView(content()) },
|
|
427
430
|
barrierDismissible: barrierDismissible,
|
|
428
431
|
onDismiss: onDismiss,
|
|
429
432
|
screenId: entries.last?.id
|
|
430
|
-
)
|
|
433
|
+
))
|
|
431
434
|
}
|
|
432
435
|
|
|
433
436
|
public func showBottomSheet<V: View>(
|
|
@@ -440,8 +443,7 @@ public final class Navigator: ObservableObject {
|
|
|
440
443
|
useBottomInset: Bool = true,
|
|
441
444
|
footer: (() -> AnyView)? = nil
|
|
442
445
|
) {
|
|
443
|
-
|
|
444
|
-
overplay = OverplayItem(
|
|
446
|
+
replaceOverplay(with: OverplayItem(
|
|
445
447
|
type: .bottomSheet,
|
|
446
448
|
content: { AnyView(content()) },
|
|
447
449
|
barrierDismissible: barrierDismissible,
|
|
@@ -452,20 +454,19 @@ public final class Navigator: ObservableObject {
|
|
|
452
454
|
useBottomInset: useBottomInset,
|
|
453
455
|
footer: footer,
|
|
454
456
|
screenId: entries.last?.id
|
|
455
|
-
)
|
|
457
|
+
))
|
|
456
458
|
}
|
|
457
459
|
|
|
458
460
|
/// Mirrors Compose's `showSnackBar(snackBar:)`.
|
|
459
461
|
public func showSnackBar(_ snackBar: SnackBar, onDismiss: (() -> Void)? = nil) {
|
|
460
|
-
|
|
461
|
-
overplay = OverplayItem(
|
|
462
|
+
replaceOverplay(with: OverplayItem(
|
|
462
463
|
type: .snackBar,
|
|
463
464
|
content: { AnyView(EmptyView()) },
|
|
464
465
|
barrierDismissible: false,
|
|
465
466
|
onDismiss: onDismiss,
|
|
466
467
|
snackBar: snackBar,
|
|
467
468
|
screenId: entries.last?.id
|
|
468
|
-
)
|
|
469
|
+
))
|
|
469
470
|
}
|
|
470
471
|
|
|
471
472
|
// MARK: Overplay dismissal
|
|
@@ -480,10 +481,23 @@ public final class Navigator: ObservableObject {
|
|
|
480
481
|
/// `hardClearAfterDismiss`). Called by a component once its exit animation
|
|
481
482
|
/// has completed.
|
|
482
483
|
public func finishOverplayDismiss() {
|
|
483
|
-
|
|
484
|
+
replaceOverplay(with: nil)
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/// Same, but only while `id` is still the overplay: an exit that ends after a newer overplay
|
|
488
|
+
/// took its place must not take that one down.
|
|
489
|
+
public func finishOverplayDismiss(id: UUID) {
|
|
490
|
+
guard overplay?.id == id else { return }
|
|
491
|
+
replaceOverplay(with: nil)
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/// A replaced overplay counts as dismissed (Compose disposes it), so its `onDismiss` fires once,
|
|
495
|
+
/// whether it was still closing or not.
|
|
496
|
+
private func replaceOverplay(with item: OverplayItem?) {
|
|
497
|
+
let replaced = overplay
|
|
484
498
|
overplayDismissHandler = nil
|
|
485
|
-
overplay =
|
|
486
|
-
|
|
499
|
+
overplay = item
|
|
500
|
+
replaced?.onDismiss?()
|
|
487
501
|
}
|
|
488
502
|
|
|
489
503
|
public func hideOverplay() {
|
|
@@ -116,7 +116,6 @@ public struct StackScreen<Content: View>: View {
|
|
|
116
116
|
@State private var isBottomTabRoot: Bool = false
|
|
117
117
|
@State private var headerHide: CGFloat = 0
|
|
118
118
|
@State private var lastScrollOffset: CGFloat = 0
|
|
119
|
-
@State private var headerSettle: Task<Void, Never>?
|
|
120
119
|
@State private var footerHeight: CGFloat = 0
|
|
121
120
|
|
|
122
121
|
@Environment(\.applicationEnvironment) private var appEnv
|
|
@@ -126,9 +125,7 @@ public struct StackScreen<Content: View>: View {
|
|
|
126
125
|
@Environment(\.appTheme) private var theme
|
|
127
126
|
@OptionalEnvironmentObject private var navigation: Navigation?
|
|
128
127
|
|
|
129
|
-
|
|
130
|
-
@State private var trackingState = ScreenTrackingState()
|
|
131
|
-
@State private var trackingAction: String = ScreenTracker.getLastScreenName() != nil ? "push" : "back"
|
|
128
|
+
@State private var tracking: Task<Void, Never>?
|
|
132
129
|
|
|
133
130
|
/// Compose's `headerOpacity()`: 0 over a banner or transparent header, 1 once the bar is opaque.
|
|
134
131
|
private var opacity: CGFloat {
|
|
@@ -189,25 +186,21 @@ public struct StackScreen<Content: View>: View {
|
|
|
189
186
|
return
|
|
190
187
|
}
|
|
191
188
|
headerHide = min(max(headerHide + delta, 0), layout.headerHeight)
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
guard !Task.isCancelled else { return }
|
|
202
|
-
let height = layout.headerHeight
|
|
203
|
-
// within the first header height the header still sits on the content
|
|
204
|
-
guard scrollOffset >= height, headerHide > 0, headerHide < height else { return }
|
|
205
|
-
withAnimation(.easeOut(duration: 0.2)) {
|
|
206
|
-
headerHide = headerHide > height / 2 ? height : 0
|
|
207
|
-
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/// A half-shown header settles fully in or out once the scroll comes to rest.
|
|
192
|
+
private func settleHeader() {
|
|
193
|
+
let height = layout.headerHeight
|
|
194
|
+
// within the first header height the header still sits on the content
|
|
195
|
+
guard useQuickReturn, scrollOffset >= height, headerHide > 0, headerHide < height else { return }
|
|
196
|
+
withAnimation(.easeOut(duration: 0.2)) {
|
|
197
|
+
headerHide = headerHide > height / 2 ? height : 0
|
|
208
198
|
}
|
|
209
199
|
}
|
|
210
200
|
|
|
201
|
+
/// Compose snaps the animated search fully in or out when the scroll stops.
|
|
202
|
+
private var searchSnapDistance: CGFloat? { inputSearchType == .animated ? HEADER_HEIGHT : nil }
|
|
203
|
+
|
|
211
204
|
private var keyboardSize: CGFloat { useAvoidKeyboard ? max(keyboardHeight, 0) : 0 }
|
|
212
205
|
|
|
213
206
|
/// What the footer, tab bar or home indicator takes from the bottom of the window, gap included.
|
|
@@ -338,35 +331,50 @@ public struct StackScreen<Content: View>: View {
|
|
|
338
331
|
}
|
|
339
332
|
.onAppear(perform: reportBottomObstruction)
|
|
340
333
|
.onChange(of: bottomObstruction) { _ in reportBottomObstruction() }
|
|
341
|
-
.
|
|
334
|
+
.onAppear(perform: startTracking)
|
|
335
|
+
.onChange(of: screenName) { _ in startTracking() }
|
|
336
|
+
.onDisappear { tracking?.cancel() }
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/// Compose tracks from a `LaunchedEffect` with `remember`ed state, so every visit — first show, a
|
|
340
|
+
/// pop back, a tab switch — starts over with fresh state. A covered hosting controller keeps its
|
|
341
|
+
/// `@State` and doesn't re-run `.task`, so the run hangs off appear / disappear instead.
|
|
342
|
+
private func startTracking() {
|
|
343
|
+
tracking?.cancel()
|
|
344
|
+
let state = ScreenTrackingState()
|
|
345
|
+
let action = ScreenTracker.getLastScreenName() != nil ? "push" : "back"
|
|
346
|
+
let maxApi = appEnv.maxApi
|
|
347
|
+
let context = appEnv.applicationContext
|
|
348
|
+
let screenName = screenName
|
|
349
|
+
tracking = Task { @MainActor in
|
|
342
350
|
ScreenTracker.trackScreenNavigated(
|
|
343
|
-
maxApi:
|
|
344
|
-
context:
|
|
351
|
+
maxApi: maxApi,
|
|
352
|
+
context: context,
|
|
345
353
|
screenName: screenName,
|
|
346
|
-
action:
|
|
347
|
-
state:
|
|
354
|
+
action: action,
|
|
355
|
+
state: state
|
|
348
356
|
)
|
|
349
357
|
do {
|
|
350
358
|
try await Task.sleep(nanoseconds: 2_000_000_000)
|
|
351
359
|
} catch {
|
|
352
360
|
return
|
|
353
361
|
}
|
|
354
|
-
let loadTime = ScreenTracker.now() -
|
|
362
|
+
let loadTime = ScreenTracker.now() - state.time
|
|
355
363
|
ScreenTracker.trackScreenDisplayed(
|
|
356
|
-
maxApi:
|
|
357
|
-
context:
|
|
364
|
+
maxApi: maxApi,
|
|
365
|
+
context: context,
|
|
358
366
|
screenName: screenName,
|
|
359
367
|
duration: loadTime,
|
|
360
|
-
state:
|
|
368
|
+
state: state
|
|
361
369
|
)
|
|
362
|
-
let interactionTime = ScreenTracker.now() -
|
|
370
|
+
let interactionTime = ScreenTracker.now() - state.time
|
|
363
371
|
ScreenTracker.trackScreenInteracted(
|
|
364
|
-
maxApi:
|
|
365
|
-
context:
|
|
372
|
+
maxApi: maxApi,
|
|
373
|
+
context: context,
|
|
366
374
|
screenName: screenName,
|
|
367
375
|
duration: interactionTime - loadTime,
|
|
368
376
|
totalDuration: interactionTime,
|
|
369
|
-
state:
|
|
377
|
+
state: state
|
|
370
378
|
)
|
|
371
379
|
}
|
|
372
380
|
}
|
|
@@ -385,6 +393,8 @@ public struct StackScreen<Content: View>: View {
|
|
|
385
393
|
scrollToTopCallback: scrollToTopCallback,
|
|
386
394
|
animatedSearchInset: animatedSearchInset,
|
|
387
395
|
bottomInset: duoTabBarInsets.overlay,
|
|
396
|
+
snapDistance: searchSnapDistance,
|
|
397
|
+
onScrollIdle: settleHeader,
|
|
388
398
|
content: { animatedHeroBody(animatedHeader) }
|
|
389
399
|
)
|
|
390
400
|
.edgesIgnoringSafeArea(.all)
|
|
@@ -419,6 +429,8 @@ public struct StackScreen<Content: View>: View {
|
|
|
419
429
|
scrollToTopCallback: scrollToTopCallback,
|
|
420
430
|
animatedSearchInset: animatedSearchInset,
|
|
421
431
|
bottomInset: duoTabBarInsets.overlay,
|
|
432
|
+
snapDistance: searchSnapDistance,
|
|
433
|
+
onScrollIdle: settleHeader,
|
|
422
434
|
content: { duoInsetContent }
|
|
423
435
|
)
|
|
424
436
|
.edgesIgnoringSafeArea(.all)
|
|
@@ -438,6 +450,8 @@ public struct StackScreen<Content: View>: View {
|
|
|
438
450
|
scrollToTopCallback: scrollToTopCallback,
|
|
439
451
|
topInset: layout.headerHeight - layout.insets.top,
|
|
440
452
|
bottomInset: duoTabBarInsets.overlay,
|
|
453
|
+
snapDistance: searchSnapDistance,
|
|
454
|
+
onScrollIdle: settleHeader,
|
|
441
455
|
content: content
|
|
442
456
|
)
|
|
443
457
|
|
|
@@ -483,6 +497,8 @@ public struct StackScreen<Content: View>: View {
|
|
|
483
497
|
scrollToTopCallback: scrollToTopCallback,
|
|
484
498
|
animatedSearchInset: animatedSearchInset,
|
|
485
499
|
bottomInset: duoTabBarInsets.overlay,
|
|
500
|
+
snapDistance: searchSnapDistance,
|
|
501
|
+
onScrollIdle: settleHeader,
|
|
486
502
|
content: content
|
|
487
503
|
)
|
|
488
504
|
}
|
|
@@ -563,11 +579,30 @@ private struct StackScreenContent<Content: View>: View {
|
|
|
563
579
|
var topInset: CGFloat = 0
|
|
564
580
|
/// Room for a bar that overlays the content (iPhone Duo floating tab bar).
|
|
565
581
|
var bottomInset: CGFloat = 0
|
|
582
|
+
/// Compose's animated-search snap: a scroll that comes to rest part way through this distance
|
|
583
|
+
/// finishes the move, up or back down.
|
|
584
|
+
var snapDistance: CGFloat? = nil
|
|
585
|
+
var onScrollIdle: (() -> Void)? = nil
|
|
566
586
|
let content: () -> Content
|
|
567
587
|
|
|
568
588
|
private let topAnchorID = "stack_screen_top_anchor"
|
|
569
589
|
private let scrollSpace = "stack_screen_scroll_space"
|
|
570
590
|
|
|
591
|
+
@State private var scrollView = WeakScrollView()
|
|
592
|
+
|
|
593
|
+
private func scrollDidRest() {
|
|
594
|
+
onScrollIdle?()
|
|
595
|
+
guard let snapDistance, scrollOffset > 0, scrollOffset < snapDistance,
|
|
596
|
+
let scrollView = scrollView.value else { return }
|
|
597
|
+
// UIKit, not `ScrollViewProxy.scrollTo`: SwiftUI keeps re-applying a scrollTo target while the
|
|
598
|
+
// content above it resizes, which this content does as the search inset shrinks with the scroll
|
|
599
|
+
let target = scrollOffset < snapDistance / 2 ? 0 : snapDistance
|
|
600
|
+
scrollView.setContentOffset(
|
|
601
|
+
CGPoint(x: scrollView.contentOffset.x, y: target - scrollView.adjustedContentInset.top),
|
|
602
|
+
animated: true
|
|
603
|
+
)
|
|
604
|
+
}
|
|
605
|
+
|
|
571
606
|
var body: some View {
|
|
572
607
|
if scrollable {
|
|
573
608
|
ScrollViewReader { proxy in
|
|
@@ -587,6 +622,7 @@ private struct StackScreenContent<Content: View>: View {
|
|
|
587
622
|
scrollOffset = -minY
|
|
588
623
|
}
|
|
589
624
|
})
|
|
625
|
+
.background(ScrollViewProbe { scrollView.value = $0 })
|
|
590
626
|
|
|
591
627
|
if animatedSearchInset + topInset > 0 {
|
|
592
628
|
Spacer().frame(height: animatedSearchInset + topInset)
|
|
@@ -602,6 +638,7 @@ private struct StackScreenContent<Content: View>: View {
|
|
|
602
638
|
}
|
|
603
639
|
}
|
|
604
640
|
.coordinateSpace(name: scrollSpace)
|
|
641
|
+
.modifier(ScrollRest(offset: scrollOffset, onRest: scrollDidRest))
|
|
605
642
|
// Status-bar tap (MoMoStatusBarTap swizzle) scrolls to top and notifies the consumer.
|
|
606
643
|
.onReceive(NotificationCenter.default.publisher(for: MoMoStatusBarTap.notificationName)) { _ in
|
|
607
644
|
withAnimation {
|
|
@@ -618,6 +655,74 @@ private struct StackScreenContent<Content: View>: View {
|
|
|
618
655
|
}
|
|
619
656
|
}
|
|
620
657
|
|
|
658
|
+
private final class WeakScrollView {
|
|
659
|
+
weak var value: UIScrollView?
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/// Finds the UIScrollView behind the enclosing SwiftUI `ScrollView` once placed in its content.
|
|
663
|
+
private struct ScrollViewProbe: UIViewRepresentable {
|
|
664
|
+
let found: (UIScrollView) -> Void
|
|
665
|
+
|
|
666
|
+
func makeUIView(context: Context) -> Probe { Probe(found: found) }
|
|
667
|
+
|
|
668
|
+
func updateUIView(_ uiView: Probe, context: Context) {}
|
|
669
|
+
|
|
670
|
+
final class Probe: UIView {
|
|
671
|
+
let found: (UIScrollView) -> Void
|
|
672
|
+
|
|
673
|
+
init(found: @escaping (UIScrollView) -> Void) {
|
|
674
|
+
self.found = found
|
|
675
|
+
super.init(frame: .zero)
|
|
676
|
+
isUserInteractionEnabled = false
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
required init?(coder: NSCoder) { nil }
|
|
680
|
+
|
|
681
|
+
override func didMoveToWindow() {
|
|
682
|
+
super.didMoveToWindow()
|
|
683
|
+
var view = superview
|
|
684
|
+
while let current = view, !(current is UIScrollView) { view = current.superview }
|
|
685
|
+
if let scrollView = view as? UIScrollView { found(scrollView) }
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/// Calls `onRest` once the scroll comes to rest. Scroll phases need iOS 18; before that the scroll
|
|
691
|
+
/// counts as ended once the offset stops changing, which a finger held still also looks like.
|
|
692
|
+
private struct ScrollRest: ViewModifier {
|
|
693
|
+
let offset: CGFloat
|
|
694
|
+
let onRest: () -> Void
|
|
695
|
+
|
|
696
|
+
@State private var pending: Task<Void, Never>?
|
|
697
|
+
|
|
698
|
+
func body(content: Content) -> some View {
|
|
699
|
+
#if compiler(>=6.0)
|
|
700
|
+
if #available(iOS 18.0, *) {
|
|
701
|
+
content.onScrollPhaseChange { _, phase in
|
|
702
|
+
if phase == .idle { onRest() }
|
|
703
|
+
}
|
|
704
|
+
} else {
|
|
705
|
+
debounced(content)
|
|
706
|
+
}
|
|
707
|
+
#else
|
|
708
|
+
debounced(content)
|
|
709
|
+
#endif
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
private func debounced(_ content: Content) -> some View {
|
|
713
|
+
content
|
|
714
|
+
.onChange(of: offset) { _ in
|
|
715
|
+
pending?.cancel()
|
|
716
|
+
pending = Task { @MainActor in
|
|
717
|
+
try? await Task.sleep(nanoseconds: 150_000_000)
|
|
718
|
+
guard !Task.isCancelled else { return }
|
|
719
|
+
onRest()
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
.onDisappear { pending?.cancel() }
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
621
726
|
// MARK: - Animated search header
|
|
622
727
|
|
|
623
728
|
/// Compose's `SearchAnimated`: the search slides from below the header (scroll 0) up into the bar
|
|
@@ -633,6 +738,7 @@ private struct SearchAnimatedHeader: View {
|
|
|
633
738
|
|
|
634
739
|
@Environment(\.screenLayout) private var layout
|
|
635
740
|
@Environment(\.appTheme) private var theme
|
|
741
|
+
@OptionalEnvironmentObject private var navigator: Navigator?
|
|
636
742
|
|
|
637
743
|
var body: some View {
|
|
638
744
|
let scroll = max(scrollOffset, 0)
|
|
@@ -650,12 +756,20 @@ private struct SearchAnimatedHeader: View {
|
|
|
650
756
|
? Spacing.M
|
|
651
757
|
: headerRightWidth + (hasHeaderRight ? Spacing.M * 2 : Spacing.M))
|
|
652
758
|
|
|
653
|
-
|
|
654
|
-
let
|
|
655
|
-
let
|
|
759
|
+
// one progress for all three, so the field docks with its final insets and never covers the toolkit
|
|
760
|
+
let progress = min(scroll / HEADER_HEIGHT, 1)
|
|
761
|
+
let top = maxTop + (minTop - maxTop) * progress
|
|
762
|
+
let start = minSide + (maxStart - minSide) * progress
|
|
763
|
+
let end = minSide + (maxEnd - minSide) * progress
|
|
656
764
|
|
|
657
765
|
// padding, not a Spacer: on the iPhone Duo status bar row the collapsed top is above the safe area
|
|
658
|
-
InputSearch(props: headerSearchProps(
|
|
766
|
+
InputSearch(props: headerSearchProps(
|
|
767
|
+
props,
|
|
768
|
+
scroll: scroll,
|
|
769
|
+
layout: layout,
|
|
770
|
+
theme: theme,
|
|
771
|
+
pop: navigator.map { navigator in { navigator.pop() } }
|
|
772
|
+
))
|
|
659
773
|
.frame(height: HEADER_HEIGHT)
|
|
660
774
|
.padding(.leading, start)
|
|
661
775
|
.padding(.trailing, end)
|