@momo-kits/native-kits 0.163.1-sp.3-debug → 0.163.1-sp.4-debug
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +57 -1
- package/compose/build.gradle.kts +1 -1
- package/compose/compose.podspec +1 -1
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputSearch.kt +5 -5
- package/compose/src/commonMain/kotlin/vn/momo/kits/layout/Item.kt +4 -2
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/BottomSheet.kt +66 -19
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigator.kt +15 -3
- package/gradle.properties +1 -1
- package/ios/Application/Components.swift +14 -14
- package/ios/Application/HeaderRight.swift +45 -79
- package/ios/Application/Navigation/component/Header.swift +1 -1
- package/ios/Application/Navigation/component/HeaderRightWidthKey.swift +11 -0
- package/ios/Application/Navigation/component/NavigationFooter.swift +90 -0
- package/ios/Application/Navigation/component/NavigationHeaderRight.swift +196 -0
- package/ios/Application/StackScreen.swift +18 -21
- package/ios/Badge/BadgeRibbon.swift +4 -1
- package/ios/Button/Button.swift +54 -80
- package/ios/Icon/Icon.swift +13 -4
- package/ios/Layout/GridContext.swift +30 -0
- package/ios/Layout/Item.swift +42 -0
- package/ios/Layout/Section.swift +134 -0
- package/ios/native-kits.podspec +2 -1
- 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 +3 -2
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/Home.swift +5 -12
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/IconDemo.swift +104 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/SectionDemo.swift +100 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/SwiftUIDemoBrowserView.swift +3 -1
- package/package.json +1 -1
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
//
|
|
2
|
+
// NavigationFooter.swift
|
|
3
|
+
// MoMoUIKits
|
|
4
|
+
//
|
|
5
|
+
// SwiftUI mirror of Compose's `Footer` (navigation/StackScreen.kt): the band
|
|
6
|
+
// pinned under the screen content — surface background, S/M padding, the
|
|
7
|
+
// navigation-bar reserve, and the 6pt gradient shadow cast up onto the content.
|
|
8
|
+
//
|
|
9
|
+
// Named `NavigationFooter` to coexist with the legacy `Footer` in `Screen.swift`
|
|
10
|
+
// (kept as-is for the old `Screen` path). Used by `StackScreen`.
|
|
11
|
+
//
|
|
12
|
+
|
|
13
|
+
import SwiftUI
|
|
14
|
+
|
|
15
|
+
public struct NavigationFooter: View {
|
|
16
|
+
/// Compose's `footerComponent`: nothing renders when nil.
|
|
17
|
+
var footerComponent: (() -> AnyView)?
|
|
18
|
+
/// Current keyboard height, measured from the bottom of the screen.
|
|
19
|
+
var keyboardSize: CGFloat
|
|
20
|
+
|
|
21
|
+
public init(
|
|
22
|
+
footerComponent: (() -> AnyView)?,
|
|
23
|
+
keyboardSize: CGFloat = 0
|
|
24
|
+
) {
|
|
25
|
+
self.footerComponent = footerComponent
|
|
26
|
+
self.keyboardSize = keyboardSize
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
@Environment(\.appTheme) private var theme
|
|
30
|
+
|
|
31
|
+
/// Read from the window, not from an enclosing `GeometryProxy`: the proxy's bottom
|
|
32
|
+
/// inset grows to the keyboard height while the keyboard is up.
|
|
33
|
+
static var safeAreaBottom: CGFloat {
|
|
34
|
+
UIApplication.shared.connectedScenes
|
|
35
|
+
.compactMap { ($0 as? UIWindowScene)?.keyWindow?.safeAreaInsets.bottom }
|
|
36
|
+
.first ?? 0
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/// Compose's `AppNavigationBar.current` (`getNavigationBarHeight` clamps iOS to 21pt).
|
|
40
|
+
static var navigationBarInset: CGFloat { min(safeAreaBottom, 21) }
|
|
41
|
+
|
|
42
|
+
/// Compose's `bottomPadding + Spacing.S` with the keyboard down. The footer is laid
|
|
43
|
+
/// out inside the safe area, which already covers part of that gap.
|
|
44
|
+
static var bottomPadding: CGFloat {
|
|
45
|
+
max(navigationBarInset + Spacing.S - safeAreaBottom, 0)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/// Deliberately an offset and not padding: growing the footer feeds back into SwiftUI's
|
|
49
|
+
/// keyboard avoidance, so a padding-based lift never settles at the right place.
|
|
50
|
+
/// Negative lifts the band over the keyboard; positive shaves the few points by which
|
|
51
|
+
/// the safe area over-reserves compared to Compose. `FloatingContent` rides the same
|
|
52
|
+
/// amount so the FAB keeps its distance above the band.
|
|
53
|
+
///
|
|
54
|
+
/// Natural position is `safeAreaBottom + bottomPadding` above the bottom of the screen;
|
|
55
|
+
/// Compose puts it at `(AppNavigationBar - keyboard).coerceAtLeast(0) + Spacing.S` on
|
|
56
|
+
/// top of the `imePadding()` lift — i.e. `max(navigationBar, keyboard) + Spacing.S`.
|
|
57
|
+
static func verticalOffset(keyboardSize: CGFloat) -> CGFloat {
|
|
58
|
+
(safeAreaBottom + bottomPadding) - (max(navigationBarInset, keyboardSize) + Spacing.S)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
public var body: some View {
|
|
62
|
+
if let footerComponent = footerComponent {
|
|
63
|
+
VStack(alignment: .leading, spacing: 0) {
|
|
64
|
+
footerComponent()
|
|
65
|
+
}
|
|
66
|
+
.frame(maxWidth: .infinity, alignment: .leading)
|
|
67
|
+
.padding(.top, Spacing.S)
|
|
68
|
+
.padding(.horizontal, Spacing.M)
|
|
69
|
+
.padding(.bottom, Self.bottomPadding)
|
|
70
|
+
// Compose paints the surface all the way to the bottom of the screen; the
|
|
71
|
+
// footer's own frame stops at the safe area, so only the fill bleeds down.
|
|
72
|
+
.background(theme.colors.background.surface.ignoresSafeArea(.container, edges: .bottom))
|
|
73
|
+
.overlay(alignment: .top) { shadow }
|
|
74
|
+
.offset(y: Self.verticalOffset(keyboardSize: keyboardSize))
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/// Compose offsets the shadow box by -6dp, so it lands on the content above the
|
|
79
|
+
/// footer rather than on the footer surface itself.
|
|
80
|
+
private var shadow: some View {
|
|
81
|
+
LinearGradient(
|
|
82
|
+
colors: [Color.clear, Color.black.opacity(0.05)],
|
|
83
|
+
startPoint: .top,
|
|
84
|
+
endPoint: .bottom
|
|
85
|
+
)
|
|
86
|
+
.frame(height: 6)
|
|
87
|
+
.offset(y: -6)
|
|
88
|
+
.allowsHitTesting(false)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
import Foundation
|
|
3
|
+
|
|
4
|
+
/// Keyed (`@Environment(\.applicationEnvironment)`) variant of `HeaderRight`, for hosts
|
|
5
|
+
/// wired through `NavigationContainer`/`KitContainer` (the injection style introduced by
|
|
6
|
+
/// MR 227 / DS-637). Reuses the shared data types (`HeaderRightData`, `Tool`, `ToolGroup`)
|
|
7
|
+
/// and `NavigationButton` from `HeaderRight.swift`. The legacy `HeaderRight` there stays on
|
|
8
|
+
/// `@EnvironmentObject` for hosts that render `Screen` directly with
|
|
9
|
+
/// `.environmentObject(ApplicationEnvironment(...))`. Opt in per screen with
|
|
10
|
+
/// `headerRight: { NavigationHeaderRight() }`.
|
|
11
|
+
public struct NavigationHeaderRight: View {
|
|
12
|
+
public init(headerRight: HeaderRightData? = nil, tintColor: Color? = nil) {
|
|
13
|
+
self.headerRight = headerRight
|
|
14
|
+
self.tintColor = tintColor
|
|
15
|
+
}
|
|
16
|
+
var headerRight: HeaderRightData?
|
|
17
|
+
var tintColor: Color? = nil
|
|
18
|
+
|
|
19
|
+
public var body: some View {
|
|
20
|
+
HStack(alignment: .center) {
|
|
21
|
+
ToolkitNavigationHeaderRight(headerRight: headerRight, tintColor: tintColor)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
struct ToolkitNavigationHeaderRight: View {
|
|
27
|
+
var headerRight: HeaderRightData?
|
|
28
|
+
var tintColor: Color?
|
|
29
|
+
@State private var isFavorite: Bool = false
|
|
30
|
+
@State private var isLoading: Bool = false
|
|
31
|
+
@Environment(\.applicationEnvironment) private var environment
|
|
32
|
+
|
|
33
|
+
private func apiValue(_ value: Any?) -> Any {
|
|
34
|
+
value ?? NSNull()
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
private var contextMap: [String: Any] {
|
|
38
|
+
let context = environment.applicationContext
|
|
39
|
+
return [
|
|
40
|
+
"appId": apiValue(context?.appId),
|
|
41
|
+
"code": apiValue(context?.appCode),
|
|
42
|
+
"name": apiValue(context?.appName),
|
|
43
|
+
"icon": apiValue(context?.appIcon),
|
|
44
|
+
"description": apiValue(context?.description),
|
|
45
|
+
"support": apiValue(context?.support),
|
|
46
|
+
"toolkitConfig": apiValue(context?.toolkitConfig),
|
|
47
|
+
"providerId": apiValue(context?.providerId),
|
|
48
|
+
"permissions": apiValue(context?.permissions)
|
|
49
|
+
]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private var shouldShowShortcut: Bool {
|
|
53
|
+
headerRight?.useShortcut == true && !(headerRight?.useMore == true && environment.applicationContext == nil)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private func parseResponse(_ response: String) -> Any? {
|
|
57
|
+
guard let jsonData = response.data(using: .utf8),
|
|
58
|
+
let json = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any] else {
|
|
59
|
+
return nil
|
|
60
|
+
}
|
|
61
|
+
return json["response"]
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// MARK: - Actions
|
|
65
|
+
private func loadFavoriteState() {
|
|
66
|
+
environment.composeApi?.request(
|
|
67
|
+
funcName: "isFavoriteApp",
|
|
68
|
+
params: ["code": apiValue(environment.applicationContext?.appCode)]
|
|
69
|
+
) { response in
|
|
70
|
+
if let isFavorite = parseResponse(response) as? Bool {
|
|
71
|
+
self.isFavorite = isFavorite
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private func onPressShortcut() {
|
|
77
|
+
environment.composeApi?.request(
|
|
78
|
+
funcName: "onToolAction",
|
|
79
|
+
params: [
|
|
80
|
+
"item": ["key": "onFavorite"],
|
|
81
|
+
"context": contextMap
|
|
82
|
+
]
|
|
83
|
+
) { response in
|
|
84
|
+
if let result = parseResponse(response) as? [String: Any],
|
|
85
|
+
result["success"] as? Bool == true {
|
|
86
|
+
isFavorite.toggle()
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private func onPressHelpCenter() {
|
|
92
|
+
environment.composeApi?.request(
|
|
93
|
+
funcName: "showHelpCenter",
|
|
94
|
+
params: [
|
|
95
|
+
"appId": apiValue(environment.applicationContext?.appId),
|
|
96
|
+
"code": apiValue(environment.applicationContext?.appCode),
|
|
97
|
+
"name": apiValue(environment.applicationContext?.appName),
|
|
98
|
+
"icon": apiValue(environment.applicationContext?.appIcon),
|
|
99
|
+
"description": apiValue(environment.applicationContext?.description)
|
|
100
|
+
]
|
|
101
|
+
) { _ in }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private func onPressClose() {
|
|
105
|
+
environment.composeApi?.request(
|
|
106
|
+
funcName: "dismissAll",
|
|
107
|
+
params: nil
|
|
108
|
+
) { _ in }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private func onPressMore() {
|
|
112
|
+
let params: [String: Any] = [
|
|
113
|
+
"useSystemTools": headerRight?.useSystemTools ?? true,
|
|
114
|
+
"tools": headerRight?.tools.map { $0.toMap() } ?? [],
|
|
115
|
+
"context": contextMap
|
|
116
|
+
]
|
|
117
|
+
environment.composeApi?.request(
|
|
118
|
+
funcName: "showTools",
|
|
119
|
+
params: params
|
|
120
|
+
) { response in
|
|
121
|
+
if let toolResponse = parseResponse(response) as? String {
|
|
122
|
+
headerRight?.toolCallback?(toolResponse)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private func getNavigationButtonConfig() -> NavigationButtonConfig {
|
|
128
|
+
let totalTools = headerRight?.tools.reduce(0) { $0 + $1.items.count } ?? 0
|
|
129
|
+
var icon = isFavorite ? "pin_star_checked" : "pin_star"
|
|
130
|
+
var onClickHandler: () -> Void = onPressShortcut
|
|
131
|
+
|
|
132
|
+
if totalTools > 1 || headerRight?.useMore == true {
|
|
133
|
+
icon = "navigation_more_icon"
|
|
134
|
+
onClickHandler = onPressMore
|
|
135
|
+
} else if totalTools == 1, let singleTool = headerRight?.tools.first?.items.first {
|
|
136
|
+
icon = singleTool.icon
|
|
137
|
+
onClickHandler = {
|
|
138
|
+
headerRight?.toolCallback?(singleTool.key)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return NavigationButtonConfig(icon: icon, onPress: onClickHandler)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
var body: some View {
|
|
146
|
+
let backgroundButtonColor: Color = tintColor == Colors.black01 ? Colors.black20.opacity(0.6) : Colors.black01.opacity(0.6)
|
|
147
|
+
let borderColor: Color = tintColor == Colors.black01 ? Colors.black01.opacity(0.2) : Colors.black20.opacity(0.2)
|
|
148
|
+
let navButtonConfig = getNavigationButtonConfig()
|
|
149
|
+
let showBadge = headerRight?.tools.contains { group in
|
|
150
|
+
group.items.contains { $0.showBadge }
|
|
151
|
+
} ?? false
|
|
152
|
+
|
|
153
|
+
HStack(alignment: .center, spacing: 0) {
|
|
154
|
+
if shouldShowShortcut {
|
|
155
|
+
NavigationButton(
|
|
156
|
+
disabled: isLoading,
|
|
157
|
+
icon: navButtonConfig.icon,
|
|
158
|
+
showBadge: showBadge,
|
|
159
|
+
onClick: navButtonConfig.onPress,
|
|
160
|
+
tintColor: tintColor
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
HStack(alignment: .center, spacing: 0) {
|
|
165
|
+
if environment.applicationContext != nil {
|
|
166
|
+
Icon(source: "help_center", size: 20, color: tintColor ?? Colors.black17)
|
|
167
|
+
.padding(4)
|
|
168
|
+
.onTapGesture {
|
|
169
|
+
onPressHelpCenter()
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
Rectangle()
|
|
173
|
+
.fill(tintColor ?? Colors.black20)
|
|
174
|
+
.frame(width: 0.5, height: 12)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
Icon(source: "16_basic_home", size: 20, color: tintColor ?? Colors.black17)
|
|
178
|
+
.padding(4)
|
|
179
|
+
.onTapGesture {
|
|
180
|
+
onPressClose()
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
.frame(width: 65, height: 28)
|
|
184
|
+
.background(backgroundButtonColor)
|
|
185
|
+
.cornerRadius(14)
|
|
186
|
+
.overlay(
|
|
187
|
+
RoundedRectangle(cornerRadius: 14)
|
|
188
|
+
.stroke(borderColor, lineWidth: 0.2)
|
|
189
|
+
)
|
|
190
|
+
.padding(.leading, Spacing.S)
|
|
191
|
+
}
|
|
192
|
+
.onAppear {
|
|
193
|
+
loadFavoriteState()
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// SwiftUI mirror of Compose's `StackScreen`. This is a refactor of `Screen`:
|
|
6
6
|
// the body is decomposed into the same focused pieces Compose uses — header
|
|
7
7
|
// layer, scrollable content (`StackScreenContent`), floating button
|
|
8
|
-
// (`FloatingContent`) and footer (`
|
|
8
|
+
// (`FloatingContent`) and footer (`NavigationFooter`) — instead of one
|
|
9
9
|
// monolithic, partially-duplicated view. The public init matches `Screen`
|
|
10
10
|
// so call sites (e.g. NavigationContainer's ScreenHost) can swap over.
|
|
11
11
|
//
|
|
@@ -165,12 +165,13 @@ public struct StackScreen<Content: View>: View {
|
|
|
165
165
|
|
|
166
166
|
FloatingContent(
|
|
167
167
|
fabProps: fabProps,
|
|
168
|
-
|
|
168
|
+
keyboardSize: keyboardSize,
|
|
169
|
+
hasFooter: footer != nil,
|
|
169
170
|
scrollOffset: scrollOffset
|
|
170
171
|
)
|
|
171
172
|
}
|
|
172
173
|
|
|
173
|
-
|
|
174
|
+
NavigationFooter(footerComponent: footer, keyboardSize: keyboardSize)
|
|
174
175
|
}
|
|
175
176
|
|
|
176
177
|
// Animated search bar overlay (mirrors Compose's SearchAnimated layer).
|
|
@@ -444,17 +445,29 @@ private struct SearchAnimatedHeader: View {
|
|
|
444
445
|
/// Mirrors Compose's `FloatingContent`: no FAB props means nothing renders.
|
|
445
446
|
private struct FloatingContent: View {
|
|
446
447
|
let fabProps: FabProps?
|
|
447
|
-
let
|
|
448
|
+
let keyboardSize: CGFloat
|
|
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
|
|
448
452
|
let scrollOffset: CGFloat
|
|
449
453
|
|
|
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
|
+
|
|
450
462
|
var body: some View {
|
|
451
463
|
if let fabProps = fabProps {
|
|
452
464
|
FloatingButton(
|
|
453
465
|
props: fabProps,
|
|
454
|
-
keyboardOffset:
|
|
466
|
+
keyboardOffset: 0,
|
|
455
467
|
scrollOffset: scrollOffset
|
|
456
468
|
)
|
|
457
469
|
.padding(.horizontal, Spacing.M)
|
|
470
|
+
.offset(y: verticalOffset)
|
|
458
471
|
.ignoresSafeArea(.keyboard, edges: .bottom)
|
|
459
472
|
}
|
|
460
473
|
}
|
|
@@ -483,19 +496,3 @@ private struct HideKeyboardOnTap: ViewModifier {
|
|
|
483
496
|
}
|
|
484
497
|
}
|
|
485
498
|
}
|
|
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,6 +174,7 @@ public struct BadgeRibbon: View {
|
|
|
174
174
|
)
|
|
175
175
|
.frame(width: RibbonConstants.headTailWidth, height: RibbonConstants.headTailHeight)
|
|
176
176
|
.rotationEffect(.degrees(180))
|
|
177
|
+
.offset(x: 1)
|
|
177
178
|
.clipped()
|
|
178
179
|
}
|
|
179
180
|
|
|
@@ -183,6 +184,7 @@ public struct BadgeRibbon: View {
|
|
|
183
184
|
placeholder: AnyView(Color.clear)
|
|
184
185
|
)
|
|
185
186
|
.frame(width: RibbonConstants.headTailWidth, height: RibbonConstants.headTailHeight)
|
|
187
|
+
.offset(x: 1)
|
|
186
188
|
.clipped()
|
|
187
189
|
}
|
|
188
190
|
|
|
@@ -192,6 +194,7 @@ public struct BadgeRibbon: View {
|
|
|
192
194
|
placeholder: AnyView(Color.clear)
|
|
193
195
|
)
|
|
194
196
|
.frame(width: RibbonConstants.skewTailWidth, height: RibbonConstants.skewTailHeight)
|
|
197
|
+
.offset(x: -2)
|
|
195
198
|
.clipped()
|
|
196
199
|
}
|
|
197
200
|
}
|
|
@@ -281,7 +284,7 @@ private struct RibbonConstants {
|
|
|
281
284
|
static let ribbonHeight: CGFloat = 20
|
|
282
285
|
static let roundHeight: CGFloat = 16
|
|
283
286
|
static let skewBodyHeight: CGFloat = 16
|
|
284
|
-
static let roundRightRadius: CGFloat =
|
|
287
|
+
static let roundRightRadius: CGFloat = 8
|
|
285
288
|
static let roundPaddingEnd: CGFloat = 6
|
|
286
289
|
static let skewTailWidth: CGFloat = 8
|
|
287
290
|
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: String
|
|
137
|
+
var iconRight: String
|
|
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 = "Button",
|
|
144
144
|
action: @escaping () -> Void,
|
|
145
145
|
type: ButtonType = .primary,
|
|
146
146
|
size: ButtonSize = .large,
|
|
147
|
-
iconLeft:
|
|
148
|
-
iconRight:
|
|
147
|
+
iconLeft: String = "",
|
|
148
|
+
iconRight: String = "",
|
|
149
149
|
isFull: Bool = true,
|
|
150
150
|
loading: Bool = false,
|
|
151
151
|
useTintColor: Bool = true
|
|
@@ -161,38 +161,9 @@ public struct Button: View {
|
|
|
161
161
|
self.useTintColor = useTintColor
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
|
164
|
+
private func shouldLoadingOnLeft(_ left: String, _ right: String) -> Bool {
|
|
165
|
+
let hasLeft = !left.isEmpty
|
|
166
|
+
let hasRight = !right.isEmpty
|
|
196
167
|
|
|
197
168
|
switch (hasLeft, hasRight) {
|
|
198
169
|
case (false, false): return true
|
|
@@ -217,27 +188,13 @@ public struct Button: View {
|
|
|
217
188
|
}
|
|
218
189
|
}) {
|
|
219
190
|
HStack(spacing: size.iconSpacing) {
|
|
220
|
-
|
|
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
|
-
}
|
|
191
|
+
renderIcon(icon: iconLeft, forceLoading: loading && loadingOnLeft, textColor: fg)
|
|
228
192
|
|
|
229
193
|
MomoText(title, typography: size.typographyStyle, color: fg)
|
|
230
194
|
.lineLimit(1)
|
|
231
195
|
.truncationMode(.tail)
|
|
232
196
|
|
|
233
|
-
|
|
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
|
-
}
|
|
197
|
+
renderIcon(icon: iconRight, forceLoading: loading && !loadingOnLeft, textColor: fg)
|
|
241
198
|
}
|
|
242
199
|
.frame(maxWidth: isFull ? .infinity : nil)
|
|
243
200
|
.padding(.horizontal, size.padding)
|
|
@@ -249,20 +206,24 @@ public struct Button: View {
|
|
|
249
206
|
border: border,
|
|
250
207
|
borderWidth: borderWidth,
|
|
251
208
|
radius: size.radius,
|
|
252
|
-
height: size.height
|
|
209
|
+
height: size.height,
|
|
210
|
+
isFull: isFull
|
|
253
211
|
))
|
|
254
212
|
.disabled(!isEnabled)
|
|
255
213
|
}
|
|
256
214
|
|
|
215
|
+
/// Mirrors Kotlin `RenderIcon`: the loader replaces the icon slot entirely, and
|
|
216
|
+
/// `useTintColor == false` leaves the icon at its own colors.
|
|
257
217
|
@ViewBuilder
|
|
258
|
-
private func
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
} else {
|
|
264
|
-
icon
|
|
218
|
+
private func renderIcon(icon: String, forceLoading: Bool, textColor: Color) -> some View {
|
|
219
|
+
let color: Color? = useTintColor ? textColor : nil
|
|
220
|
+
|
|
221
|
+
if forceLoading {
|
|
222
|
+
LottieView(name: "lottie_circle_loader", loopMode: .loop)
|
|
265
223
|
.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)
|
|
266
227
|
}
|
|
267
228
|
}
|
|
268
229
|
}
|
|
@@ -277,29 +238,42 @@ private struct ButtonPressFeedbackStyle: ButtonStyle {
|
|
|
277
238
|
let borderWidth: CGFloat
|
|
278
239
|
let radius: CGFloat
|
|
279
240
|
let height: CGFloat
|
|
241
|
+
let isFull: Bool
|
|
280
242
|
|
|
281
243
|
func makeBody(configuration: Configuration) -> some View {
|
|
282
244
|
let pressed = configuration.isPressed && isEnabled
|
|
283
245
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
246
|
+
configuration.label
|
|
247
|
+
.modifier(HugContentOnLegacyOS(isFull: isFull))
|
|
248
|
+
.opacity(pressed ? 0.5 : 1)
|
|
249
|
+
.background(
|
|
250
|
+
RoundedRectangle(cornerRadius: radius)
|
|
251
|
+
.fill(background)
|
|
252
|
+
.overlay(
|
|
253
|
+
RoundedRectangle(cornerRadius: radius)
|
|
254
|
+
.strokeBorder(border ?? .clear, lineWidth: border != nil ? borderWidth : 0)
|
|
255
|
+
)
|
|
256
|
+
.padding(.horizontal, pressed ? 2 : 0)
|
|
257
|
+
)
|
|
258
|
+
.frame(height: height)
|
|
259
|
+
.clipShape(RoundedRectangle(cornerRadius: radius))
|
|
260
|
+
.animation(.easeInOut(duration: 0.1), value: pressed)
|
|
261
|
+
}
|
|
262
|
+
}
|
|
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)
|
|
295
277
|
}
|
|
296
|
-
// Pin the whole button to its intended height explicitly: the bare
|
|
297
|
-
// RoundedRectangle background has no intrinsic size of its own, so left
|
|
298
|
-
// unconstrained in a ZStack inside a non-height-constraining container
|
|
299
|
-
// (e.g. a footer VStack) it can report an oversized ideal height and
|
|
300
|
-
// stretch the button — and its container — far taller than intended.
|
|
301
|
-
.frame(height: height)
|
|
302
|
-
.clipShape(RoundedRectangle(cornerRadius: radius))
|
|
303
|
-
.animation(.easeInOut(duration: 0.1), value: pressed)
|
|
304
278
|
}
|
|
305
279
|
}
|
package/ios/Icon/Icon.swift
CHANGED
|
@@ -26,17 +26,24 @@ let iconSources: [String: IconSource] = loadIconSources()
|
|
|
26
26
|
// Icons that must never be tinted (mirror Compose `noThemeIcons`)
|
|
27
27
|
let noThemeIcons: [String] = ["ic_round_momo_tiny"]
|
|
28
28
|
|
|
29
|
+
// Compose defaults `color` to the theme text color while `color = null` means "no tint".
|
|
30
|
+
// Swift can't tell an omitted argument from an explicit `nil`, so the default is this
|
|
31
|
+
// unrepresentable sentinel: omitted -> theme text color, `nil` -> untinted.
|
|
32
|
+
public let iconThemeColor = Color(.sRGB, red: -1, green: -1, blue: -1, opacity: -1)
|
|
33
|
+
|
|
29
34
|
// Icon ViewModel
|
|
30
35
|
public struct Icon: View {
|
|
36
|
+
@Environment(\.appTheme) private var theme
|
|
37
|
+
|
|
31
38
|
var source: String
|
|
32
39
|
var size: CGFloat
|
|
33
40
|
var color: Color?
|
|
34
41
|
var accessibilityLabel: String?
|
|
35
|
-
|
|
42
|
+
|
|
36
43
|
public init(
|
|
37
44
|
source: String,
|
|
38
45
|
size: CGFloat = 24,
|
|
39
|
-
color: Color? =
|
|
46
|
+
color: Color? = iconThemeColor,
|
|
40
47
|
accessibilityLabel: String? = nil
|
|
41
48
|
) {
|
|
42
49
|
self.source = source
|
|
@@ -46,9 +53,11 @@ public struct Icon: View {
|
|
|
46
53
|
}
|
|
47
54
|
|
|
48
55
|
public var body: some View {
|
|
49
|
-
|
|
56
|
+
// Mirror Compose: an https source is used as-is, anything else is a key into icon.json
|
|
57
|
+
if let uri = source.contains("https") ? source : iconSources[source]?.uri {
|
|
58
|
+
let requestedColor = color == iconThemeColor ? theme.colors.text.default : color
|
|
50
59
|
// Mirror Compose: certain icons (e.g. branded glyphs) are never tinted
|
|
51
|
-
let resolvedColor = noThemeIcons.contains(source) ? nil :
|
|
60
|
+
let resolvedColor = noThemeIcons.contains(source) ? nil : requestedColor
|
|
52
61
|
ImageView(uri, color: resolvedColor)
|
|
53
62
|
.frame(width: size, height: size)
|
|
54
63
|
// Mirrors Compose's auto-generated `contentDescription = "img|$iconUrl"`
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
|
|
3
|
+
/// Grid metrics published by a layout container (`SectionView`) so span-based children
|
|
4
|
+
/// can size themselves. Mirrors `vn.momo.kits.layout.GridContext`.
|
|
5
|
+
public struct GridContext: Equatable {
|
|
6
|
+
public let numberOfColumns: Int
|
|
7
|
+
public let gutter: CGFloat
|
|
8
|
+
public let sizePerSpan: CGFloat
|
|
9
|
+
|
|
10
|
+
public init(numberOfColumns: Int = 12, gutter: CGFloat = Spacing.M, sizePerSpan: CGFloat = 0) {
|
|
11
|
+
self.numberOfColumns = numberOfColumns
|
|
12
|
+
self.gutter = gutter
|
|
13
|
+
self.sizePerSpan = sizePerSpan
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
public func sizeForSpan(_ span: Int) -> CGFloat {
|
|
17
|
+
sizePerSpan * CGFloat(span) + gutter * CGFloat(span - 1)
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
private struct GridContextKey: EnvironmentKey {
|
|
22
|
+
static let defaultValue: GridContext? = nil
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
public extension EnvironmentValues {
|
|
26
|
+
var gridContext: GridContext? {
|
|
27
|
+
get { self[GridContextKey.self] }
|
|
28
|
+
set { self[GridContextKey.self] = newValue }
|
|
29
|
+
}
|
|
30
|
+
}
|