@momo-kits/native-kits 0.162.21-debug → 0.162.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/compose/build.gradle.kts +1 -1
- package/compose/compose.podspec +1 -1
- package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderRight.kt +1 -1
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigation.kt +3 -1
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/StackScreen.kt +26 -7
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTab.kt +3 -1
- package/gradle.properties +1 -1
- package/ios/Application/ApplicationEnvironment.swift +4 -2
- package/ios/Application/MaxApi.swift +18 -0
- package/ios/Application/Navigation/NavigationContainer.swift +21 -3
- package/ios/Application/Navigation/Navigator.swift +50 -29
- package/ios/Application/Navigation/Overplay/ModalScreen.swift +6 -0
- package/ios/Application/Navigation/Tracking/ScreenTracker.swift +117 -0
- package/ios/Application/StackScreen.swift +41 -0
- package/ios/Badge/BadgeRibbon.swift +80 -0
- package/ios/Button/Button.swift +67 -11
- package/ios/Carousel/Carousel.swift +16 -1
- package/ios/Checkbox/Checkbox.swift +14 -20
- package/ios/Chip/Chip.swift +35 -26
- package/ios/Collapse/Collapse.swift +11 -1
- package/ios/DateTimePicker/DateTimePicker.swift +4 -2
- package/ios/DateTimePicker/WheelPicker.swift +7 -3
- package/ios/Extensions/ActiveOpacityButtonStyle.swift +14 -0
- package/ios/Icon/Icon.swift +5 -1
- package/ios/IconButton/IconButton.swift +35 -7
- package/ios/Image/Image.swift +16 -3
- package/ios/Information/Information.swift +3 -1
- package/ios/Input/ErrorView.swift +3 -1
- package/ios/Input/Input.swift +31 -8
- package/ios/Input/InputPhoneNumber.swift +14 -3
- package/ios/Input/InputSearch.swift +7 -1
- package/ios/Input/InputTextArea.swift +78 -34
- package/ios/InputDropDown/InputDropDown.swift +4 -1
- package/ios/InputMoney/InputMoney.swift +16 -4
- package/ios/Popup/PopupDisplay.swift +3 -4
- package/ios/Popup/PopupNotify.swift +3 -4
- package/ios/Popup/PopupPromotion.swift +16 -8
- package/ios/ProgressInfo/ProgressInfo.swift +4 -2
- package/ios/Radio/Radio.swift +12 -13
- package/ios/Rating/Rating.swift +1 -1
- package/ios/Stepper/Stepper.swift +2 -2
- package/ios/Steps/Steps.swift +22 -6
- package/ios/SuggestAction/SuggestAction.swift +5 -3
- package/ios/Swipeable/SwipeCell.swift +5 -3
- package/ios/Switch/Switch.swift +53 -28
- package/ios/TabView/TabView.swift +18 -14
- package/ios/Template/TrustBanner/TrustBanner.swift +6 -7
- package/ios/Tooltip/Tooltip.swift +3 -0
- package/ios/Uploader/Uploader.swift +0 -9
- package/ios/native-kits.podspec +1 -1
- package/ios-demo/MoMoUIKitsDemo.podspec +1 -1
- package/package.json +1 -1
package/compose/build.gradle.kts
CHANGED
package/compose/compose.podspec
CHANGED
|
@@ -85,7 +85,9 @@ data class NavigationOptions(
|
|
|
85
85
|
val footerComponent: @Composable (() -> Unit)? = null,
|
|
86
86
|
val floatingButtonProps: FloatingButtonProps? = null,
|
|
87
87
|
val keyboardOptions: KeyboardOptions = KeyboardOptions()
|
|
88
|
-
)
|
|
88
|
+
) {
|
|
89
|
+
internal fun hasFooter() : Boolean = footerComponent != null
|
|
90
|
+
}
|
|
89
91
|
|
|
90
92
|
data class KeyboardOptions(
|
|
91
93
|
val keyboardShouldPersistTaps: Boolean = false,
|
|
@@ -56,6 +56,7 @@ import vn.momo.kits.const.Colors
|
|
|
56
56
|
import vn.momo.kits.const.Spacing
|
|
57
57
|
import vn.momo.kits.modifier.conditional
|
|
58
58
|
import vn.momo.kits.modifier.hideKeyboardOnTap
|
|
59
|
+
import vn.momo.kits.navigation.bottomtab.BOTTOM_TAB_BAR_HEIGHT
|
|
59
60
|
import vn.momo.kits.navigation.component.FABPosition
|
|
60
61
|
import vn.momo.kits.navigation.component.FloatingButton
|
|
61
62
|
import vn.momo.kits.navigation.component.HEADER_HEIGHT
|
|
@@ -77,6 +78,13 @@ import kotlin.time.ExperimentalTime
|
|
|
77
78
|
internal val LocalFooterHeightPx = staticCompositionLocalOf { mutableIntStateOf(0) }
|
|
78
79
|
internal val LocalHeaderRightWidthPx = staticCompositionLocalOf { mutableIntStateOf(0) }
|
|
79
80
|
|
|
81
|
+
@Composable
|
|
82
|
+
private fun bottomTabBarInset(): Dp {
|
|
83
|
+
if (LocalNavigation.current.bottomTabIndex == -1) return 0.dp
|
|
84
|
+
val keyboard = keyboardSizeState().value
|
|
85
|
+
return (BOTTOM_TAB_BAR_HEIGHT.dp + Spacing.S - keyboard).coerceAtLeast(0.dp)
|
|
86
|
+
}
|
|
87
|
+
|
|
80
88
|
@OptIn(ExperimentalMaterialApi::class, ExperimentalTime::class)
|
|
81
89
|
@Composable
|
|
82
90
|
internal fun StackScreen(
|
|
@@ -195,12 +203,12 @@ internal fun StackScreen(
|
|
|
195
203
|
}
|
|
196
204
|
|
|
197
205
|
Column(Modifier.zIndex(2f).fillMaxSize()) {
|
|
198
|
-
MainContent(content = content,
|
|
206
|
+
MainContent(content = content, isBottomTabRoot = isBottomTabRoot, isBottomTabChild = isBottomTabChild)
|
|
199
207
|
FooterContent()
|
|
200
208
|
}
|
|
201
209
|
|
|
202
210
|
Box(Modifier.zIndex(6f)) {
|
|
203
|
-
FloatingContent()
|
|
211
|
+
FloatingContent(isBottomTabChild = isBottomTabChild)
|
|
204
212
|
}
|
|
205
213
|
|
|
206
214
|
OverplayView(isBottomTabChild = isBottomTabChild, id = id)
|
|
@@ -210,7 +218,7 @@ internal fun StackScreen(
|
|
|
210
218
|
|
|
211
219
|
@Suppress("FrequentlyChangingValue")
|
|
212
220
|
@Composable
|
|
213
|
-
internal fun FloatingContent() {
|
|
221
|
+
internal fun FloatingContent(isBottomTabChild: Boolean) {
|
|
214
222
|
val options = LocalOptions.current
|
|
215
223
|
val density = LocalDensity.current
|
|
216
224
|
val footerHeightPx = LocalFooterHeightPx.current
|
|
@@ -219,7 +227,11 @@ internal fun FloatingContent() {
|
|
|
219
227
|
|
|
220
228
|
val fabProps = options.floatingButtonProps ?: return
|
|
221
229
|
val bottomPadding = fabProps.bottom
|
|
222
|
-
?: (Spacing.M +
|
|
230
|
+
?: (Spacing.M + when {
|
|
231
|
+
options.hasFooter() -> with(density) { footerHeightPx.intValue.toDp() }
|
|
232
|
+
isBottomTabChild -> bottomTabBarInset() + (navigationBar - keyboardSizeState().value).coerceAtLeast(0.dp)
|
|
233
|
+
else -> navigationBar
|
|
234
|
+
})
|
|
223
235
|
|
|
224
236
|
FloatingButton(
|
|
225
237
|
scrollPosition = fabProps.scrollState?.value ?: scrollState.value,
|
|
@@ -235,7 +247,7 @@ internal fun FloatingContent() {
|
|
|
235
247
|
}
|
|
236
248
|
|
|
237
249
|
@Composable
|
|
238
|
-
internal fun ColumnScope.MainContent(content: @Composable () -> Unit,
|
|
250
|
+
internal fun ColumnScope.MainContent(content: @Composable () -> Unit, isBottomTabRoot: Boolean, isBottomTabChild: Boolean) {
|
|
239
251
|
val options = LocalOptions.current
|
|
240
252
|
val inputSearchType = getInputSearchType(options)
|
|
241
253
|
val density = LocalDensity.current
|
|
@@ -264,7 +276,14 @@ internal fun ColumnScope.MainContent(content: @Composable () -> Unit, isBottomTa
|
|
|
264
276
|
.conditional(options.scrollData.scrollable && options.scrollData.scrollState is ScrollState) {
|
|
265
277
|
verticalScroll(scrollState)
|
|
266
278
|
}
|
|
267
|
-
.padding(
|
|
279
|
+
.padding(
|
|
280
|
+
bottom = when {
|
|
281
|
+
options.hasFooter() || isBottomTabRoot -> 0.dp
|
|
282
|
+
isBottomTabChild -> bottomTabBarInset() + (navigationBar - keyboardSizeState().value).coerceAtLeast(0.dp)
|
|
283
|
+
!options.scrollData.scrollable -> 0.dp
|
|
284
|
+
else -> navigationBar
|
|
285
|
+
}
|
|
286
|
+
)
|
|
268
287
|
) {
|
|
269
288
|
ScreenContent(content = content)
|
|
270
289
|
}
|
|
@@ -294,7 +313,7 @@ internal fun ScreenContent(content: @Composable () -> Unit) {
|
|
|
294
313
|
@Composable
|
|
295
314
|
internal fun FooterContent() {
|
|
296
315
|
val options = LocalOptions.current
|
|
297
|
-
if (options.
|
|
316
|
+
if (options.hasFooter()) {
|
|
298
317
|
val keyboardSize = keyboardSizeState()
|
|
299
318
|
val bottomPadding = (AppNavigationBar.current - keyboardSize.value).coerceAtLeast(0.dp)
|
|
300
319
|
Footer(footerComponent = options.footerComponent, bottomPadding = bottomPadding)
|
|
@@ -18,6 +18,7 @@ import androidx.navigation.compose.rememberNavController
|
|
|
18
18
|
import vn.momo.kits.const.AppNavigationBar
|
|
19
19
|
import vn.momo.kits.const.AppTheme
|
|
20
20
|
import vn.momo.kits.const.Spacing
|
|
21
|
+
import vn.momo.kits.navigation.KeyboardOptions
|
|
21
22
|
import vn.momo.kits.navigation.LocalNavigation
|
|
22
23
|
import vn.momo.kits.navigation.LocalNavigator
|
|
23
24
|
import vn.momo.kits.navigation.NavigationOptions
|
|
@@ -53,7 +54,8 @@ fun BottomTab(
|
|
|
53
54
|
LaunchedEffect(Unit){
|
|
54
55
|
navigation.markAsBottomTabRoot()
|
|
55
56
|
navigation.setOptions(
|
|
56
|
-
headerType = HeaderType.None
|
|
57
|
+
headerType = HeaderType.None,
|
|
58
|
+
keyboardOptions = KeyboardOptions(useAvoidKeyboard = false)
|
|
57
59
|
)
|
|
58
60
|
}
|
|
59
61
|
|
package/gradle.properties
CHANGED
|
@@ -41,11 +41,13 @@ public class ApplicationEnvironment: ObservableObject {
|
|
|
41
41
|
let applicationContext: MiniAppContext?
|
|
42
42
|
let composeApi: KitComposeApi?
|
|
43
43
|
let config: KitConfig?
|
|
44
|
-
|
|
45
|
-
|
|
44
|
+
let maxApi: MaxApi?
|
|
45
|
+
|
|
46
|
+
public init(applicationContext: MiniAppContext? = nil, composeApi: KitComposeApi? = nil, config: KitConfig? = nil, maxApi: MaxApi? = nil) {
|
|
46
47
|
self.applicationContext = applicationContext
|
|
47
48
|
self.composeApi = composeApi
|
|
48
49
|
self.config = config
|
|
50
|
+
self.maxApi = maxApi
|
|
49
51
|
}
|
|
50
52
|
}
|
|
51
53
|
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
//
|
|
2
|
+
// MaxApi.swift
|
|
3
|
+
// MoMoUIKits
|
|
4
|
+
//
|
|
5
|
+
// Swift mirror of the host-app bridge exposed on Kotlin as `vn.momo.maxapi.IMaxApi`
|
|
6
|
+
// (`LocalMaxApi`). `IMaxApi` itself is an external compiled dependency with no
|
|
7
|
+
// source in this repo, so this protocol intentionally exposes only the methods
|
|
8
|
+
// this kit's Kotlin source actually calls (`track`, `dismiss`, `getLanguage`) —
|
|
9
|
+
// not a full guess at IMaxApi's real surface.
|
|
10
|
+
//
|
|
11
|
+
|
|
12
|
+
import Foundation
|
|
13
|
+
|
|
14
|
+
public protocol MaxApi {
|
|
15
|
+
func track(eventName: String, params: [String: Any?])
|
|
16
|
+
func dismiss(completion: (() -> Void)?)
|
|
17
|
+
func getLanguage(callback: @escaping ([String: Any?]?) -> Void)
|
|
18
|
+
}
|
|
@@ -18,6 +18,7 @@ public struct NavigationContainer<Initial: View>: View {
|
|
|
18
18
|
|
|
19
19
|
private let setNavigator: ((Navigator) -> Void)?
|
|
20
20
|
private let composeApi: KitComposeApi?
|
|
21
|
+
private let maxApi: MaxApi?
|
|
21
22
|
|
|
22
23
|
public init(
|
|
23
24
|
initialScreenName: String = "",
|
|
@@ -26,6 +27,7 @@ public struct NavigationContainer<Initial: View>: View {
|
|
|
26
27
|
applicationContext: MiniAppContext? = nil,
|
|
27
28
|
composeApi: KitComposeApi? = nil,
|
|
28
29
|
config: KitConfig? = nil,
|
|
30
|
+
maxApi: MaxApi? = nil,
|
|
29
31
|
localize: Localize? = nil,
|
|
30
32
|
setNavigator: ((Navigator) -> Void)? = nil
|
|
31
33
|
) {
|
|
@@ -40,11 +42,13 @@ public struct NavigationContainer<Initial: View>: View {
|
|
|
40
42
|
wrappedValue: ApplicationEnvironment(
|
|
41
43
|
applicationContext: applicationContext,
|
|
42
44
|
composeApi: composeApi,
|
|
43
|
-
config: config
|
|
45
|
+
config: config,
|
|
46
|
+
maxApi: maxApi
|
|
44
47
|
)
|
|
45
48
|
)
|
|
46
49
|
_localize = StateObject(wrappedValue: localize ?? Localize())
|
|
47
50
|
self.composeApi = composeApi
|
|
51
|
+
self.maxApi = maxApi
|
|
48
52
|
self.setNavigator = setNavigator
|
|
49
53
|
}
|
|
50
54
|
|
|
@@ -80,12 +84,19 @@ public struct NavigationContainer<Initial: View>: View {
|
|
|
80
84
|
navigator.composeApi = composeApi
|
|
81
85
|
}
|
|
82
86
|
setNavigator?(navigator)
|
|
87
|
+
// Mirrors Compose's LaunchedEffect(maxApi, resolvedLocalize): host-reported
|
|
88
|
+
// language wins over the default "vi" only when it resolves to English.
|
|
89
|
+
maxApi?.getLanguage { data in
|
|
90
|
+
if parseLanguage(data) == Localize.EN {
|
|
91
|
+
localize.changeLanguage(Localize.EN)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
83
94
|
}
|
|
84
95
|
}
|
|
85
96
|
|
|
86
97
|
@ViewBuilder
|
|
87
98
|
private func screenView(for route: DynamicScreenRoute) -> some View {
|
|
88
|
-
if let screen =
|
|
99
|
+
if let screen = navigator.registry.getScreen(id: route.id) {
|
|
89
100
|
ScreenHost(screen: screen, navigator: navigator)
|
|
90
101
|
.navigationBarBackButtonHidden(true)
|
|
91
102
|
} else {
|
|
@@ -95,7 +106,7 @@ public struct NavigationContainer<Initial: View>: View {
|
|
|
95
106
|
|
|
96
107
|
@ViewBuilder
|
|
97
108
|
private func screenView(forDialog route: DynamicDialogRoute) -> some View {
|
|
98
|
-
if let screen =
|
|
109
|
+
if let screen = navigator.registry.getScreen(id: route.id) {
|
|
99
110
|
ScreenHost(screen: screen, navigator: navigator)
|
|
100
111
|
} else {
|
|
101
112
|
EmptyView()
|
|
@@ -134,6 +145,7 @@ private struct ScreenHost: View {
|
|
|
134
145
|
layoutOffset: options.layoutOffset,
|
|
135
146
|
inputSearchProps: options.inputSearchProps,
|
|
136
147
|
fabProps: options.fabProps,
|
|
148
|
+
screenName: screen.name,
|
|
137
149
|
content: { screen.content() }
|
|
138
150
|
)
|
|
139
151
|
}
|
|
@@ -180,3 +192,9 @@ private func safeAreaBottomInset() -> CGFloat {
|
|
|
180
192
|
.compactMap { ($0 as? UIWindowScene)?.keyWindow?.safeAreaInsets.bottom }
|
|
181
193
|
.first ?? 0
|
|
182
194
|
}
|
|
195
|
+
|
|
196
|
+
private func parseLanguage(_ raw: [String: Any?]?) -> String? {
|
|
197
|
+
guard let response = (raw?["response"] as? String)?.trimmingCharacters(in: .whitespaces).lowercased(),
|
|
198
|
+
!response.isEmpty else { return nil }
|
|
199
|
+
return response.hasPrefix(Localize.EN) ? Localize.EN : Localize.VI
|
|
200
|
+
}
|
|
@@ -30,12 +30,10 @@ public struct DynamicScreen {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
public final class DynamicScreenRegistry {
|
|
33
|
-
public static let shared = DynamicScreenRegistry()
|
|
34
|
-
|
|
35
33
|
private var screens: [Int: DynamicScreen] = [:]
|
|
36
34
|
private var idCounter: Int = 1
|
|
37
35
|
|
|
38
|
-
|
|
36
|
+
public init() {}
|
|
39
37
|
|
|
40
38
|
@discardableResult
|
|
41
39
|
public func register(
|
|
@@ -95,6 +93,11 @@ public final class Navigator: ObservableObject {
|
|
|
95
93
|
@Published public var presented: DynamicDialogRoute?
|
|
96
94
|
@Published public var overplay: OverplayItem?
|
|
97
95
|
|
|
96
|
+
/// Owned per-Navigator (mirrors Compose's `remember { DynamicScreenRegistry() }` +
|
|
97
|
+
/// disposal): screens registered by this navigator are released via ARC when the
|
|
98
|
+
/// navigator itself deallocates, instead of accumulating in a process-wide singleton.
|
|
99
|
+
public let registry = DynamicScreenRegistry()
|
|
100
|
+
|
|
98
101
|
/// Animated-close handler registered by the active overplay component
|
|
99
102
|
/// (mirrors Compose's `OverplayComponentRegistry.bindClose`). When set, a
|
|
100
103
|
/// dismiss request runs the component's exit animation before the item is
|
|
@@ -111,7 +114,7 @@ public final class Navigator: ObservableObject {
|
|
|
111
114
|
composeApi: KitComposeApi? = nil
|
|
112
115
|
) {
|
|
113
116
|
self.composeApi = composeApi
|
|
114
|
-
self.rootRoute =
|
|
117
|
+
self.rootRoute = registry.register(
|
|
115
118
|
screenName: initialScreenName,
|
|
116
119
|
content: initialContent,
|
|
117
120
|
options: options
|
|
@@ -121,7 +124,7 @@ public final class Navigator: ObservableObject {
|
|
|
121
124
|
// MARK: Stack
|
|
122
125
|
|
|
123
126
|
public func push<V: View>(@ViewBuilder _ content: @escaping () -> V, options: NavigationOptions? = nil) {
|
|
124
|
-
let route =
|
|
127
|
+
let route = registry.register(
|
|
125
128
|
screenName: options?.screenName ?? "",
|
|
126
129
|
content: { AnyView(content()) },
|
|
127
130
|
options: options
|
|
@@ -134,7 +137,7 @@ public final class Navigator: ObservableObject {
|
|
|
134
137
|
@ViewBuilder _ content: @escaping () -> V,
|
|
135
138
|
options: NavigationOptions? = nil
|
|
136
139
|
) {
|
|
137
|
-
let route =
|
|
140
|
+
let route = registry.register(
|
|
138
141
|
screenName: screenName,
|
|
139
142
|
content: { AnyView(content()) },
|
|
140
143
|
options: options
|
|
@@ -145,7 +148,7 @@ public final class Navigator: ObservableObject {
|
|
|
145
148
|
public func replace<V: View>(@ViewBuilder _ content: @escaping () -> V, options: NavigationOptions? = nil) {
|
|
146
149
|
guard !path.isEmpty else { return }
|
|
147
150
|
let removed = path.removeLast()
|
|
148
|
-
|
|
151
|
+
registry.unregister(id: removed.id)
|
|
149
152
|
push(content, options: options)
|
|
150
153
|
}
|
|
151
154
|
|
|
@@ -156,15 +159,15 @@ public final class Navigator: ObservableObject {
|
|
|
156
159
|
) {
|
|
157
160
|
guard !path.isEmpty else { return }
|
|
158
161
|
let removed = path.removeLast()
|
|
159
|
-
|
|
162
|
+
registry.unregister(id: removed.id)
|
|
160
163
|
push(screenName: screenName, content, options: options)
|
|
161
164
|
}
|
|
162
165
|
|
|
163
166
|
public func reset<V: View>(@ViewBuilder _ content: @escaping () -> V, options: NavigationOptions? = nil) {
|
|
164
|
-
for route in path {
|
|
165
|
-
|
|
167
|
+
for route in path { registry.unregister(id: route.id) }
|
|
168
|
+
registry.unregister(id: rootRoute.id)
|
|
166
169
|
path.removeAll()
|
|
167
|
-
let route =
|
|
170
|
+
let route = registry.register(
|
|
168
171
|
screenName: options?.screenName ?? "",
|
|
169
172
|
content: { AnyView(content()) },
|
|
170
173
|
options: options
|
|
@@ -177,10 +180,10 @@ public final class Navigator: ObservableObject {
|
|
|
177
180
|
@ViewBuilder _ content: @escaping () -> V,
|
|
178
181
|
options: NavigationOptions? = nil
|
|
179
182
|
) {
|
|
180
|
-
for route in path {
|
|
181
|
-
|
|
183
|
+
for route in path { registry.unregister(id: route.id) }
|
|
184
|
+
registry.unregister(id: rootRoute.id)
|
|
182
185
|
path.removeAll()
|
|
183
|
-
let route =
|
|
186
|
+
let route = registry.register(
|
|
184
187
|
screenName: screenName,
|
|
185
188
|
content: { AnyView(content()) },
|
|
186
189
|
options: options
|
|
@@ -191,17 +194,16 @@ public final class Navigator: ObservableObject {
|
|
|
191
194
|
public func pop(_ count: Int = 1, callback: (() -> Void)? = nil) {
|
|
192
195
|
var remaining = count
|
|
193
196
|
while remaining > 0 {
|
|
194
|
-
if overplay
|
|
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 {
|
|
195
205
|
hideOverplay()
|
|
196
|
-
} else if
|
|
197
|
-
let dismissedId = presented?.id
|
|
198
|
-
presented = nil
|
|
199
|
-
if let id = dismissedId { DynamicScreenRegistry.shared.unregister(id: id) }
|
|
200
|
-
} else if !path.isEmpty {
|
|
201
|
-
let removed = path.removeLast()
|
|
202
|
-
DynamicScreenRegistry.shared.unregister(id: removed.id)
|
|
203
|
-
} else {
|
|
204
|
-
composeApi?.requestCallback(funcName: "dismiss", params: nil, onResponse: nil)
|
|
206
|
+
} else if !popScreen() {
|
|
205
207
|
break
|
|
206
208
|
}
|
|
207
209
|
remaining -= 1
|
|
@@ -209,13 +211,32 @@ public final class Navigator: ObservableObject {
|
|
|
209
211
|
callback?()
|
|
210
212
|
}
|
|
211
213
|
|
|
214
|
+
/// Pops the topmost presented screen or stack entry. Returns `false` (and requests
|
|
215
|
+
/// a host dismiss) when there is nothing left to pop.
|
|
216
|
+
@discardableResult
|
|
217
|
+
private func popScreen() -> Bool {
|
|
218
|
+
if presented != nil {
|
|
219
|
+
let dismissedId = presented?.id
|
|
220
|
+
presented = nil
|
|
221
|
+
if let id = dismissedId { registry.unregister(id: id) }
|
|
222
|
+
return true
|
|
223
|
+
} else if !path.isEmpty {
|
|
224
|
+
let removed = path.removeLast()
|
|
225
|
+
registry.unregister(id: removed.id)
|
|
226
|
+
return true
|
|
227
|
+
} else {
|
|
228
|
+
composeApi?.requestCallback(funcName: "dismiss", params: nil, onResponse: nil)
|
|
229
|
+
return false
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
212
233
|
public func popToRoot() {
|
|
213
|
-
for route in path {
|
|
234
|
+
for route in path { registry.unregister(id: route.id) }
|
|
214
235
|
path.removeAll()
|
|
215
236
|
}
|
|
216
237
|
|
|
217
238
|
public func onBackSafe(callback: (() -> Void)? = nil) {
|
|
218
|
-
let latest =
|
|
239
|
+
let latest = registry.latest()
|
|
219
240
|
if let onBack = latest?.options?.onBackHandler {
|
|
220
241
|
onBack()
|
|
221
242
|
return
|
|
@@ -256,7 +277,7 @@ public final class Navigator: ObservableObject {
|
|
|
256
277
|
// MARK: Modal-style screen (vertical slide)
|
|
257
278
|
|
|
258
279
|
public func present<V: View>(@ViewBuilder _ content: @escaping () -> V, options: NavigationOptions? = nil) {
|
|
259
|
-
let route =
|
|
280
|
+
let route = registry.register(
|
|
260
281
|
screenName: options?.screenName ?? "",
|
|
261
282
|
content: { AnyView(content()) },
|
|
262
283
|
options: options
|
|
@@ -269,7 +290,7 @@ public final class Navigator: ObservableObject {
|
|
|
269
290
|
@ViewBuilder _ content: @escaping () -> V,
|
|
270
291
|
options: NavigationOptions? = nil
|
|
271
292
|
) {
|
|
272
|
-
let route =
|
|
293
|
+
let route = registry.register(
|
|
273
294
|
screenName: screenName,
|
|
274
295
|
content: { AnyView(content()) },
|
|
275
296
|
options: options
|
|
@@ -279,7 +300,7 @@ public final class Navigator: ObservableObject {
|
|
|
279
300
|
|
|
280
301
|
public func dismiss() {
|
|
281
302
|
if let id = presented?.id {
|
|
282
|
-
|
|
303
|
+
registry.unregister(id: id)
|
|
283
304
|
}
|
|
284
305
|
presented = nil
|
|
285
306
|
}
|
|
@@ -15,6 +15,7 @@ struct ModalScreen<Content: View>: View {
|
|
|
15
15
|
let content: () -> Content
|
|
16
16
|
|
|
17
17
|
@EnvironmentObject private var navigator: Navigator
|
|
18
|
+
@Environment(\.applicationEnvironment) private var appEnv
|
|
18
19
|
|
|
19
20
|
@State private var scrimOpacity: CGFloat = 0
|
|
20
21
|
@State private var contentScale: CGFloat = 0.8
|
|
@@ -38,6 +39,11 @@ struct ModalScreen<Content: View>: View {
|
|
|
38
39
|
.onAppear {
|
|
39
40
|
guard !didAppear else { return }
|
|
40
41
|
didAppear = true
|
|
42
|
+
ScreenTracker.trackPopupDisplayed(
|
|
43
|
+
maxApi: appEnv.maxApi,
|
|
44
|
+
context: appEnv.applicationContext,
|
|
45
|
+
parentScreenName: ScreenTracker.getLastScreenName()
|
|
46
|
+
)
|
|
41
47
|
navigator.registerOverplayDismiss { close() }
|
|
42
48
|
open()
|
|
43
49
|
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
//
|
|
2
|
+
// ScreenTracker.swift
|
|
3
|
+
// MoMoUIKits
|
|
4
|
+
//
|
|
5
|
+
// SwiftUI mirror of Compose's `navigation/tracking/ScreenTracker.kt`. Pure
|
|
6
|
+
// logic, no platform dependency beyond the `MaxApi` bridge.
|
|
7
|
+
//
|
|
8
|
+
|
|
9
|
+
import Foundation
|
|
10
|
+
|
|
11
|
+
public final class ScreenTrackingState {
|
|
12
|
+
public var time: Double = Date().timeIntervalSince1970 * 1000
|
|
13
|
+
public var releaseNavigated: Bool = false
|
|
14
|
+
public var releaseLoad: Bool = false
|
|
15
|
+
public var releaseInteraction: Bool = false
|
|
16
|
+
public var previousScreenName: String?
|
|
17
|
+
public var action: String = "push"
|
|
18
|
+
|
|
19
|
+
public init() {}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
public enum ScreenTracker {
|
|
23
|
+
private static var lastScreenName: String?
|
|
24
|
+
|
|
25
|
+
public static func trackScreenNavigated(
|
|
26
|
+
maxApi: MaxApi?,
|
|
27
|
+
context: MiniAppContext?,
|
|
28
|
+
screenName: String,
|
|
29
|
+
action: String,
|
|
30
|
+
state: ScreenTrackingState
|
|
31
|
+
) {
|
|
32
|
+
guard !state.releaseNavigated else { return }
|
|
33
|
+
|
|
34
|
+
let previousScreen = lastScreenName
|
|
35
|
+
lastScreenName = screenName
|
|
36
|
+
|
|
37
|
+
var params = buildBaseParams(context: context, screenName: screenName)
|
|
38
|
+
params["pre_screen_name"] = previousScreen
|
|
39
|
+
params["state"] = "navigated"
|
|
40
|
+
params["action"] = action
|
|
41
|
+
maxApi?.track(eventName: "auto_screen_navigated", params: params)
|
|
42
|
+
|
|
43
|
+
state.releaseNavigated = true
|
|
44
|
+
state.previousScreenName = previousScreen
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
public static func trackScreenDisplayed(
|
|
48
|
+
maxApi: MaxApi?,
|
|
49
|
+
context: MiniAppContext?,
|
|
50
|
+
screenName: String,
|
|
51
|
+
duration: Double,
|
|
52
|
+
state: ScreenTrackingState
|
|
53
|
+
) {
|
|
54
|
+
guard !state.releaseLoad else { return }
|
|
55
|
+
|
|
56
|
+
var params = buildBaseParams(context: context, screenName: screenName)
|
|
57
|
+
params["state"] = "load"
|
|
58
|
+
params["duration"] = duration
|
|
59
|
+
maxApi?.track(eventName: "auto_screen_displayed", params: params)
|
|
60
|
+
|
|
61
|
+
state.releaseLoad = true
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
public static func trackScreenInteracted(
|
|
65
|
+
maxApi: MaxApi?,
|
|
66
|
+
context: MiniAppContext?,
|
|
67
|
+
screenName: String,
|
|
68
|
+
duration: Double,
|
|
69
|
+
totalDuration: Double,
|
|
70
|
+
state: ScreenTrackingState
|
|
71
|
+
) {
|
|
72
|
+
guard !state.releaseInteraction else { return }
|
|
73
|
+
|
|
74
|
+
var params = buildBaseParams(context: context, screenName: screenName)
|
|
75
|
+
params["state"] = "interaction"
|
|
76
|
+
params["duration"] = duration
|
|
77
|
+
params["total_duration"] = totalDuration
|
|
78
|
+
maxApi?.track(eventName: "auto_screen_interacted", params: params)
|
|
79
|
+
|
|
80
|
+
state.releaseInteraction = true
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
public static func trackPopupDisplayed(
|
|
84
|
+
maxApi: MaxApi?,
|
|
85
|
+
context: MiniAppContext?,
|
|
86
|
+
parentScreenName: String?
|
|
87
|
+
) {
|
|
88
|
+
maxApi?.track(eventName: "auto_popup_displayed", params: [
|
|
89
|
+
"screen_name": parentScreenName,
|
|
90
|
+
"tracking_source": 1,
|
|
91
|
+
"app_id": context?.appId,
|
|
92
|
+
"feature_code": context?.appCode,
|
|
93
|
+
"kits_version": nil,
|
|
94
|
+
"component_name": "Modal"
|
|
95
|
+
])
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private static func buildBaseParams(
|
|
99
|
+
context: MiniAppContext?,
|
|
100
|
+
screenName: String,
|
|
101
|
+
componentName: String = "Screen"
|
|
102
|
+
) -> [String: Any?] {
|
|
103
|
+
[
|
|
104
|
+
"app_id": context?.appId,
|
|
105
|
+
"feature_code": context?.appCode,
|
|
106
|
+
"screen_name": screenName,
|
|
107
|
+
"component_name": componentName,
|
|
108
|
+
"tracking_source": 1
|
|
109
|
+
]
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
public static func getLastScreenName() -> String? { lastScreenName }
|
|
113
|
+
|
|
114
|
+
public static func reset() {
|
|
115
|
+
lastScreenName = nil
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -39,6 +39,7 @@ public struct StackScreen<Content: View>: View {
|
|
|
39
39
|
var layoutOffset: CGFloat
|
|
40
40
|
var inputSearchProps: InputSearchProps?
|
|
41
41
|
var fabProps: FabProps?
|
|
42
|
+
var screenName: String = ""
|
|
42
43
|
var content: () -> Content
|
|
43
44
|
|
|
44
45
|
public init(
|
|
@@ -66,6 +67,7 @@ public struct StackScreen<Content: View>: View {
|
|
|
66
67
|
layoutOffset: CGFloat = 56,
|
|
67
68
|
inputSearchProps: InputSearchProps? = nil,
|
|
68
69
|
fabProps: FabProps? = nil,
|
|
70
|
+
screenName: String = "",
|
|
69
71
|
@ViewBuilder content: @escaping () -> Content
|
|
70
72
|
) {
|
|
71
73
|
self.backgroundColor = backgroundColor
|
|
@@ -92,6 +94,7 @@ public struct StackScreen<Content: View>: View {
|
|
|
92
94
|
self.layoutOffset = layoutOffset
|
|
93
95
|
self.inputSearchProps = inputSearchProps
|
|
94
96
|
self.fabProps = fabProps
|
|
97
|
+
self.screenName = screenName
|
|
95
98
|
self.content = content
|
|
96
99
|
}
|
|
97
100
|
|
|
@@ -100,6 +103,13 @@ public struct StackScreen<Content: View>: View {
|
|
|
100
103
|
@State private var headerRightWidth: CGFloat = 0
|
|
101
104
|
@State private var isBottomTabRoot: Bool = false
|
|
102
105
|
|
|
106
|
+
@Environment(\.applicationEnvironment) private var appEnv
|
|
107
|
+
|
|
108
|
+
// Auto-tracking state (mirrors Compose's `remember { ScreenTrackingState() }` +
|
|
109
|
+
// the once-computed push/back `action`).
|
|
110
|
+
@State private var trackingState = ScreenTrackingState()
|
|
111
|
+
@State private var trackingAction: String = ScreenTracker.getLastScreenName() != nil ? "push" : "back"
|
|
112
|
+
|
|
103
113
|
/// Header background fade tied to scroll: 0 → transparent (over a hero image
|
|
104
114
|
/// or transparent header), 1 → opaque surface with the drop shadow.
|
|
105
115
|
private var scrollOpacity: CGFloat { min(scrollOffset / 114, 1) }
|
|
@@ -193,6 +203,37 @@ public struct StackScreen<Content: View>: View {
|
|
|
193
203
|
// taps still reach buttons/fields while also dismissing the keyboard.
|
|
194
204
|
.modifier(HideKeyboardOnTap(enabled: keyboardShouldPersistTaps))
|
|
195
205
|
}
|
|
206
|
+
.task(id: screenName) {
|
|
207
|
+
ScreenTracker.trackScreenNavigated(
|
|
208
|
+
maxApi: appEnv.maxApi,
|
|
209
|
+
context: appEnv.applicationContext,
|
|
210
|
+
screenName: screenName,
|
|
211
|
+
action: trackingAction,
|
|
212
|
+
state: trackingState
|
|
213
|
+
)
|
|
214
|
+
do {
|
|
215
|
+
try await Task.sleep(nanoseconds: 2_000_000_000)
|
|
216
|
+
} catch {
|
|
217
|
+
return
|
|
218
|
+
}
|
|
219
|
+
let loadTime = Date().timeIntervalSince1970 * 1000 - trackingState.time
|
|
220
|
+
ScreenTracker.trackScreenDisplayed(
|
|
221
|
+
maxApi: appEnv.maxApi,
|
|
222
|
+
context: appEnv.applicationContext,
|
|
223
|
+
screenName: screenName,
|
|
224
|
+
duration: loadTime,
|
|
225
|
+
state: trackingState
|
|
226
|
+
)
|
|
227
|
+
let interactionTime = Date().timeIntervalSince1970 * 1000 - trackingState.time
|
|
228
|
+
ScreenTracker.trackScreenInteracted(
|
|
229
|
+
maxApi: appEnv.maxApi,
|
|
230
|
+
context: appEnv.applicationContext,
|
|
231
|
+
screenName: screenName,
|
|
232
|
+
duration: interactionTime - loadTime,
|
|
233
|
+
totalDuration: interactionTime,
|
|
234
|
+
state: trackingState
|
|
235
|
+
)
|
|
236
|
+
}
|
|
196
237
|
}
|
|
197
238
|
|
|
198
239
|
// MARK: - Header + content layer
|