@momo-kits/native-kits 0.162.2-sp.7-debug → 0.162.2-sp.8-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 +15 -1
- package/CLAUDE.md +78 -0
- package/compose/build.gradle.kts +12 -31
- package/compose/build.gradle.kts.backup +11 -30
- package/compose/compose.podspec +1 -1
- package/compose/src/androidMain/kotlin/vn/momo/kits/platform/OsFontScale.android.kt +7 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/application/AnimationSearchInput.kt +2 -7
- package/compose/src/commonMain/kotlin/vn/momo/kits/application/Context.kt +8 -11
- package/compose/src/commonMain/kotlin/vn/momo/kits/application/FloatingButton.kt +6 -24
- package/compose/src/commonMain/kotlin/vn/momo/kits/application/Header.kt +4 -19
- package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderAnimated.kt +1 -10
- package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderBackground.kt +2 -2
- package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderDefault.kt +1 -1
- package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderExtended.kt +1 -1
- package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderRight.kt +6 -17
- package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderTitle.kt +1 -1
- package/compose/src/commonMain/kotlin/vn/momo/kits/application/LiteScreen.kt +6 -101
- package/compose/src/commonMain/kotlin/vn/momo/kits/application/Localize.kt +269 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/application/NavigationContainer.kt +8 -19
- package/compose/src/commonMain/kotlin/vn/momo/kits/application/Screen.kt +8 -33
- package/compose/src/commonMain/kotlin/vn/momo/kits/application/useHeaderSearchAnimation.kt +1 -1
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Badge.kt +22 -25
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/BadgeRibbon.kt +27 -29
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/BaselineView.kt +3 -26
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Button.kt +30 -31
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Carousel.kt +3 -13
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/CheckBox.kt +0 -5
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Chip.kt +3 -11
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Collapse.kt +3 -18
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/CupertinoOverscroll.kt +11 -11
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/IconButton.kt +42 -65
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Image.kt +69 -26
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Information.kt +1 -5
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Input.kt +84 -59
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputDropDown.kt +13 -71
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputMoney.kt +10 -51
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputOTP.kt +6 -1
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputPhoneNumber.kt +4 -21
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputSearch.kt +1 -1
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputTextArea.kt +16 -75
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/LazyColumnWithBouncing.kt +5 -26
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/PaginationDot.kt +1 -1
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/PaginationScroll.kt +3 -11
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/PopupNotify.kt +16 -40
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/PopupPromotion.kt +3 -9
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/ProgressInfo.kt +0 -28
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Radio.kt +2 -13
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/ScaleSizeScope.kt +4 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Skeleton.kt +4 -10
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Slider.kt +3 -28
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Stepper.kt +8 -47
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Steps.kt +4 -25
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/SuggestAction.kt +2 -11
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Swipe.kt +1 -17
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Switch.kt +1 -6
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/TabView.kt +50 -111
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Tag.kt +1 -5
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Text.kt +1 -5
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Title.kt +1 -12
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Tooltip.kt +4 -96
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/TrustBanner.kt +5 -7
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Uploader.kt +2 -12
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/DateTimePicker.kt +9 -21
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/DateTimePickerTypes.kt +2 -2
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/DateTimePickerUtils.kt +9 -14
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/WheelPicker.kt +8 -11
- package/compose/src/commonMain/kotlin/vn/momo/kits/const/KitFontScale.kt +22 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/const/Theme.kt +1 -1
- package/compose/src/commonMain/kotlin/vn/momo/kits/const/Typography.kt +11 -20
- package/compose/src/commonMain/kotlin/vn/momo/kits/layout/Item.kt +1 -1
- package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/AutomationId.kt +0 -5
- package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/Clickable.kt +0 -2
- package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/DeprecatedModifier.kt +94 -4
- package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/Shadow.kt +0 -2
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/BottomSheet.kt +2 -2
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/ModalScreen.kt +3 -7
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigation.kt +9 -15
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/NavigationContainer.kt +36 -24
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigator.kt +48 -33
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/StackScreen.kt +67 -61
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTab.kt +6 -13
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTabBar.kt +13 -31
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/CurvedContainer.kt +1 -1
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/FloatingButton.kt +6 -24
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/Header.kt +11 -6
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderBackground.kt +2 -1
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderRight.kt +9 -6
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderTitle.kt +1 -1
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderUser.kt +3 -13
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/SnackBar.kt +3 -2
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/tracking/ScreenTracker.kt +1 -40
- package/compose/src/commonMain/kotlin/vn/momo/kits/platform/ComposeLottieAnimation.kt +0 -2
- package/compose/src/commonMain/kotlin/vn/momo/kits/platform/OsFontScale.kt +9 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/platform/Platform.kt +0 -8
- package/compose/src/commonMain/kotlin/vn/momo/kits/utils/Resources.kt +2 -17
- package/compose/src/commonMain/kotlin/vn/momo/kits/utils/Tracking.kt +1 -1
- package/compose/src/commonMain/kotlin/vn/momo/kits/utils/Utils.kt +3 -3
- package/compose/src/iosMain/kotlin/vn/momo/kits/navigation/ScrollToTop.ios.kt +2 -3
- 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 +0 -2
- package/gradle/libs.versions.toml +2 -0
- package/gradle.properties +1 -1
- package/ios/Application/ApplicationEnvironment.swift +4 -2
- package/ios/Application/Components.swift +16 -0
- package/ios/Application/Localize.swift +277 -0
- package/ios/Application/MaxApi.swift +18 -0
- package/ios/Application/Navigation/BottomTab/BottomTab.swift +1 -0
- package/ios/Application/Navigation/NavigationContainer.swift +27 -3
- package/ios/Application/Navigation/Navigator.swift +50 -29
- package/ios/Application/Navigation/Overplay/BottomSheet.swift +87 -19
- package/ios/Application/Navigation/Overplay/ModalScreen.swift +6 -0
- package/ios/Application/Navigation/Tracking/ScreenTracker.swift +117 -0
- package/ios/Application/Screen.swift +1 -1
- package/ios/Application/StackScreen.swift +49 -1
- package/ios/Badge/BadgeRibbon.swift +80 -0
- package/ios/Button/Button.swift +68 -12
- 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/Colors+Radius+Spacing/Colors.swift +9 -6
- package/ios/Colors+Radius+Spacing/Spacing.swift +3 -1
- package/ios/DateTimePicker/DateTimePicker.swift +4 -2
- package/ios/DateTimePicker/WheelPicker.swift +7 -3
- package/ios/Extensions/ActiveOpacityButtonStyle.swift +14 -0
- package/ios/Extensions/Color++.swift +5 -2
- 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/PARITY_AUDIT.md +641 -0
- 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/Typography/FontScaleStore.swift +19 -0
- package/ios/Typography/Text.swift +3 -0
- package/ios/Typography/Typography.swift +8 -5
- 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/ios/Colors+Radius+Spacing/MoMoUIKitsResources.swift +0 -2
|
@@ -43,42 +43,84 @@ struct BottomSheetView<Content: View>: View {
|
|
|
43
43
|
let content: () -> Content
|
|
44
44
|
|
|
45
45
|
@EnvironmentObject private var navigator: Navigator
|
|
46
|
+
@Environment(\.appTheme) private var theme
|
|
47
|
+
@Environment(\.applicationEnvironment) private var appEnv
|
|
46
48
|
|
|
47
49
|
/// Vertical translation of the sheet; starts off-screen and animates to 0.
|
|
48
50
|
@State private var sheetOffset: CGFloat = UIScreen.main.bounds.height
|
|
49
|
-
|
|
51
|
+
/// Mirrors Compose's `backgroundAlpha`: only touched by open()/close(), never by drag.
|
|
52
|
+
@State private var backgroundAlpha: CGFloat = 0
|
|
50
53
|
@State private var sheetHeight: CGFloat = 0
|
|
51
54
|
@State private var didAppear = false
|
|
55
|
+
@State private var keyboardHeight: CGFloat = 0
|
|
56
|
+
/// Live drag offset, isolated from `sheetOffset` so a touch-move only invalidates
|
|
57
|
+
/// this gesture-scoped value instead of forcing the whole body (including
|
|
58
|
+
/// `content()`) to reconstruct every frame — mirrors the pattern already used in
|
|
59
|
+
/// `ios/PopupView/PopupView.swift` for smooth drag-to-dismiss.
|
|
60
|
+
@GestureState private var dragTranslation: CGFloat = 0
|
|
52
61
|
|
|
53
62
|
/// Mirrors Compose's `sheetCloseOffset` (100dp drag threshold to dismiss).
|
|
54
63
|
private let closeThreshold: CGFloat = 100
|
|
55
64
|
|
|
65
|
+
/// Mirrors Compose's per-term peak (0.3f). Combined with `backgroundAlpha` the
|
|
66
|
+
/// steady-state (fully open) scrim peaks at 0.6, matching ModalScreen's peak.
|
|
67
|
+
private let maxDynamicAlpha: CGFloat = 0.3
|
|
68
|
+
|
|
56
69
|
private var screenHeight: CGFloat { UIScreen.main.bounds.height }
|
|
57
70
|
private var bottomInset: CGFloat { safeAreaBottomInset() }
|
|
71
|
+
/// While the keyboard is up, it already covers the home-indicator area, so it
|
|
72
|
+
/// replaces (rather than adds to) the usual bottom-inset filler.
|
|
73
|
+
private var effectiveBottomInset: CGFloat { keyboardHeight > 0 ? keyboardHeight : bottomInset }
|
|
58
74
|
|
|
59
75
|
private var backgroundColor: Color {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
76
|
+
isSurface ? theme.colors.background.surface : theme.colors.background.default
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private var effectiveOffset: CGFloat { sheetOffset + dragTranslation }
|
|
80
|
+
|
|
81
|
+
/// Mirrors Compose's `dynamicAlpha`: a plain derived value (not itself animated)
|
|
82
|
+
/// that tracks the current offset live, shrinking toward 0 as the sheet is
|
|
83
|
+
/// dragged down.
|
|
84
|
+
private var dynamicAlpha: CGFloat {
|
|
85
|
+
let denom = sheetHeight > 0 ? sheetHeight : screenHeight
|
|
86
|
+
guard denom > 0 else { return maxDynamicAlpha }
|
|
87
|
+
let progress = min(max(1 - effectiveOffset / denom, 0), 1)
|
|
88
|
+
return progress * maxDynamicAlpha
|
|
63
89
|
}
|
|
64
90
|
|
|
91
|
+
/// Mirrors Compose's `alpha = backgroundAlpha + dynamicAlpha`: additive, so the
|
|
92
|
+
/// scrim peaks at 0.6 at rest and only fades to a 0.3 floor while dragging
|
|
93
|
+
/// (backgroundAlpha holds steady until an explicit close()).
|
|
94
|
+
private var totalScrimAlpha: CGFloat { backgroundAlpha + dynamicAlpha }
|
|
95
|
+
|
|
65
96
|
var body: some View {
|
|
66
97
|
ZStack(alignment: .bottom) {
|
|
67
|
-
// Dimmed scrim
|
|
68
|
-
Color.black.opacity(
|
|
69
|
-
.ignoresSafeArea()
|
|
98
|
+
// Dimmed scrim — additive backgroundAlpha + dynamicAlpha (see above).
|
|
99
|
+
Color.black.opacity(totalScrimAlpha)
|
|
70
100
|
.contentShape(Rectangle())
|
|
71
101
|
.onTapGesture {
|
|
72
102
|
if barrierDismissible { close() }
|
|
73
103
|
}
|
|
74
104
|
|
|
75
105
|
sheet
|
|
76
|
-
.offset(y:
|
|
106
|
+
.offset(y: effectiveOffset)
|
|
77
107
|
}
|
|
78
108
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
109
|
+
.ignoresSafeArea()
|
|
110
|
+
.onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillChangeFrameNotification)) { notification in
|
|
111
|
+
guard let endFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return }
|
|
112
|
+
withAnimation(.easeOut(duration: 0.25)) {
|
|
113
|
+
keyboardHeight = UIScreen.main.bounds.height - endFrame.origin.y
|
|
114
|
+
}
|
|
115
|
+
}
|
|
79
116
|
.onAppear {
|
|
80
117
|
guard !didAppear else { return }
|
|
81
118
|
didAppear = true
|
|
119
|
+
ScreenTracker.trackPopupDisplayed(
|
|
120
|
+
maxApi: appEnv.maxApi,
|
|
121
|
+
context: appEnv.applicationContext,
|
|
122
|
+
parentScreenName: ScreenTracker.getLastScreenName()
|
|
123
|
+
)
|
|
82
124
|
navigator.registerOverplayDismiss { close() }
|
|
83
125
|
open()
|
|
84
126
|
}
|
|
@@ -103,16 +145,16 @@ struct BottomSheetView<Content: View>: View {
|
|
|
103
145
|
.gesture(dragGesture)
|
|
104
146
|
|
|
105
147
|
Rectangle()
|
|
106
|
-
.fill(
|
|
148
|
+
.fill(theme.colors.border.default)
|
|
107
149
|
.frame(height: 1)
|
|
108
150
|
|
|
109
151
|
content()
|
|
110
152
|
|
|
111
153
|
backgroundColor
|
|
112
|
-
.frame(height:
|
|
154
|
+
.frame(height: effectiveBottomInset)
|
|
113
155
|
}
|
|
114
156
|
.frame(maxWidth: .infinity)
|
|
115
|
-
.frame(
|
|
157
|
+
.frame(alignment: .bottom)
|
|
116
158
|
.background(
|
|
117
159
|
GeometryReader { geo in
|
|
118
160
|
Color.clear.onAppear { sheetHeight = geo.size.height }
|
|
@@ -143,13 +185,33 @@ struct BottomSheetView<Content: View>: View {
|
|
|
143
185
|
// MARK: - Drag handling (mirrors Compose's detectDragGestures)
|
|
144
186
|
|
|
145
187
|
private var dragGesture: some Gesture {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
188
|
+
// `.global` (not the default `.local`) so the gesture tracks against the
|
|
189
|
+
// window rather than the handle's own view, which is itself repositioned
|
|
190
|
+
// every frame by this same gesture's output (`sheet.offset(y: effectiveOffset)`
|
|
191
|
+
// in `body`) — the local coordinate space chasing its own moving reference
|
|
192
|
+
// frame is a plausible source of a continuous drag-time jitter.
|
|
193
|
+
DragGesture(coordinateSpace: .global)
|
|
194
|
+
.onChanged { _ in
|
|
195
|
+
// Touching the handle should immediately settle `sheetOffset` to its
|
|
196
|
+
// resting value (0) with no animation, so a drag that starts while the
|
|
197
|
+
// open animation — or the keyboard-driven resize, which shifts this
|
|
198
|
+
// same bottom-anchored top edge — is still in flight doesn't fight a
|
|
199
|
+
// moving target. Without this, the live drag value chases an animated
|
|
200
|
+
// one, producing a couple of corrective jumps before it settles.
|
|
201
|
+
if sheetOffset != 0 {
|
|
202
|
+
sheetOffset = 0
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
.updating($dragTranslation) { value, state, _ in
|
|
206
|
+
state = max(value.translation.height, 0)
|
|
150
207
|
}
|
|
151
|
-
.onEnded {
|
|
152
|
-
|
|
208
|
+
.onEnded { value in
|
|
209
|
+
let finalOffset = max(value.translation.height, 0)
|
|
210
|
+
// Capture into persisted state BEFORE @GestureState resets to 0,
|
|
211
|
+
// avoiding a one-frame snap back to rest before close()'s own
|
|
212
|
+
// slide-out animation starts (mirrors PopupView's `lastDragPosition`).
|
|
213
|
+
sheetOffset = finalOffset
|
|
214
|
+
if finalOffset > closeThreshold {
|
|
153
215
|
close()
|
|
154
216
|
} else {
|
|
155
217
|
withAnimation(.spring(response: 0.3, dampingFraction: 0.9)) {
|
|
@@ -163,17 +225,23 @@ struct BottomSheetView<Content: View>: View {
|
|
|
163
225
|
|
|
164
226
|
private func open() {
|
|
165
227
|
DispatchQueue.main.async {
|
|
166
|
-
withAnimation(.
|
|
228
|
+
withAnimation(.timingCurve(0.05, 0.7, 0.1, 1, duration: 0.1)) { backgroundAlpha = maxDynamicAlpha }
|
|
167
229
|
withAnimation(.timingCurve(0.05, 0.7, 0.1, 1, duration: 0.35)) { sheetOffset = 0 }
|
|
168
230
|
}
|
|
169
231
|
}
|
|
170
232
|
|
|
233
|
+
/// Mirrors Compose's `closeEvent`: slide out over 200ms, then fade the scrim
|
|
234
|
+
/// out over 100ms only after the slide finishes, then dismiss.
|
|
171
235
|
private func close() {
|
|
172
236
|
withAnimation(.timingCurve(0.3, 0, 0.8, 0.15, duration: 0.2)) {
|
|
173
237
|
sheetOffset = max(sheetHeight, screenHeight)
|
|
174
|
-
scrimAlpha = 0
|
|
175
238
|
}
|
|
176
239
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
|
240
|
+
withAnimation(.timingCurve(0.3, 0, 0.8, 0.15, duration: 0.1)) {
|
|
241
|
+
backgroundAlpha = 0
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
|
177
245
|
navigator.finishOverplayDismiss()
|
|
178
246
|
}
|
|
179
247
|
}
|
|
@@ -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
|
+
}
|
|
@@ -237,7 +237,7 @@ struct Footer: View {
|
|
|
237
237
|
}
|
|
238
238
|
.padding(.vertical, Spacing.S)
|
|
239
239
|
.padding(.horizontal, Spacing.M)
|
|
240
|
-
.padding(.bottom, keyboardSize
|
|
240
|
+
.padding(.bottom, keyboardSize - bottomInset)
|
|
241
241
|
.background(Colors.black01)
|
|
242
242
|
.overlay(
|
|
243
243
|
Rectangle()
|
|
@@ -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,12 +94,21 @@ 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
|
|
|
98
101
|
@State private var scrollOffset: CGFloat = 0
|
|
99
102
|
@State private var keyboardHeight: CGFloat = 0
|
|
100
103
|
@State private var headerRightWidth: CGFloat = 0
|
|
104
|
+
@State private var isBottomTabRoot: Bool = false
|
|
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"
|
|
101
112
|
|
|
102
113
|
/// Header background fade tied to scroll: 0 → transparent (over a hero image
|
|
103
114
|
/// or transparent header), 1 → opaque surface with the drop shadow.
|
|
@@ -135,7 +146,10 @@ public struct StackScreen<Content: View>: View {
|
|
|
135
146
|
GeometryReader { geometry in
|
|
136
147
|
let keyboardSize: CGFloat = useAvoidKeyboard ? max(keyboardHeight, 0) : 0
|
|
137
148
|
let bottomInset = min(geometry.safeAreaInsets.bottom, 21)
|
|
138
|
-
|
|
149
|
+
// Skip this screen's own bottom-inset reservation when hosting a footer
|
|
150
|
+
// OR a self-contained BottomTab surface (mirrors Compose's
|
|
151
|
+
// `if (options.footerComponent != null || isBottomTab) 0.dp else navigationBar`).
|
|
152
|
+
let contentBottomInset = (footer == nil && !isBottomTabRoot) ? bottomInset : 0
|
|
139
153
|
|
|
140
154
|
ZStack(alignment: .top) {
|
|
141
155
|
// Background color
|
|
@@ -175,6 +189,9 @@ public struct StackScreen<Content: View>: View {
|
|
|
175
189
|
.onPreferenceChange(HeaderRightWidthKey.self) { width in
|
|
176
190
|
if headerRightWidth != width { headerRightWidth = width }
|
|
177
191
|
}
|
|
192
|
+
.onPreferenceChange(IsBottomTabRootKey.self) { isRoot in
|
|
193
|
+
if isBottomTabRoot != isRoot { isBottomTabRoot = isRoot }
|
|
194
|
+
}
|
|
178
195
|
.onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillChangeFrameNotification)) { notification in
|
|
179
196
|
guard useAvoidKeyboard,
|
|
180
197
|
let endFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return }
|
|
@@ -186,6 +203,37 @@ public struct StackScreen<Content: View>: View {
|
|
|
186
203
|
// taps still reach buttons/fields while also dismissing the keyboard.
|
|
187
204
|
.modifier(HideKeyboardOnTap(enabled: keyboardShouldPersistTaps))
|
|
188
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
|
+
}
|
|
189
237
|
}
|
|
190
238
|
|
|
191
239
|
// MARK: - Header + content layer
|
|
@@ -196,6 +196,86 @@ public struct BadgeRibbon: View {
|
|
|
196
196
|
}
|
|
197
197
|
}
|
|
198
198
|
|
|
199
|
+
/// Ported from Compose's `RoundedBadgeRibbon` (BadgeRibbon.kt:164-326): a canvas-drawn
|
|
200
|
+
/// ribbon shape sized to its text, mirrored per corner via scaleX/scaleY (never an actual
|
|
201
|
+
/// rotation) — the label gets the same scale applied a second time so it stays right-reading
|
|
202
|
+
/// while only the ribbon shape flips.
|
|
203
|
+
public struct RoundedBadgeRibbon: View {
|
|
204
|
+
let text: String
|
|
205
|
+
let position: RibbonPosition
|
|
206
|
+
|
|
207
|
+
public init(text: String = "Label", position: RibbonPosition = .topLeft) {
|
|
208
|
+
self.text = text
|
|
209
|
+
self.position = position
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
private var scale: (x: CGFloat, y: CGFloat) {
|
|
213
|
+
switch position {
|
|
214
|
+
case .topLeft: return (-1, 1)
|
|
215
|
+
case .topRight: return (1, 1)
|
|
216
|
+
case .bottomRight: return (1, -1)
|
|
217
|
+
case .bottomLeft: return (-1, -1)
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
private var measuredTextSize: CGSize {
|
|
222
|
+
let fontSize = scaleSize(TypographyStyle.labelXsMedium.fontSize)
|
|
223
|
+
let font = UIFont.systemFont(ofSize: fontSize, weight: .medium)
|
|
224
|
+
return text.size(withAttributes: [.font: font])
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
public var body: some View {
|
|
228
|
+
let textSize = measuredTextSize
|
|
229
|
+
let minRibbonHeight = max(textSize.height * 1.2, 16)
|
|
230
|
+
let padding = minRibbonHeight / 4
|
|
231
|
+
let badgeHeight = minRibbonHeight + padding
|
|
232
|
+
let badgeWidth = max(textSize.width + padding * 2, 28)
|
|
233
|
+
|
|
234
|
+
ZStack {
|
|
235
|
+
Canvas { context, size in
|
|
236
|
+
let ribbonHeight = size.height - padding
|
|
237
|
+
let cornerRadius = (badgeHeight - padding) / 2
|
|
238
|
+
let mainColor = Colors.orange03
|
|
239
|
+
let tailColor = Colors.red01
|
|
240
|
+
|
|
241
|
+
let headWidth: CGFloat = 4
|
|
242
|
+
// Kotlin computes `leftSectionWidth` + `middleSectionWidth` here, but those
|
|
243
|
+
// cancel algebraically to `width - headWidth` — kept as the direct result.
|
|
244
|
+
let rightX = size.width - headWidth
|
|
245
|
+
|
|
246
|
+
let leftMiddlePath = RoundedCorners(topLeft: cornerRadius, topRight: 0, bottomLeft: cornerRadius, bottomRight: 0)
|
|
247
|
+
.path(in: CGRect(x: 0, y: 0, width: rightX + 1, height: ribbonHeight))
|
|
248
|
+
context.fill(leftMiddlePath, with: .color(mainColor))
|
|
249
|
+
|
|
250
|
+
let headPath = RoundedCorners(topLeft: 0, topRight: cornerRadius, bottomLeft: 0, bottomRight: 0)
|
|
251
|
+
.path(in: CGRect(x: rightX, y: 0, width: headWidth, height: ribbonHeight))
|
|
252
|
+
context.fill(headPath, with: .color(mainColor))
|
|
253
|
+
|
|
254
|
+
let halfHeadWidth = headWidth / 2
|
|
255
|
+
let tailStartX = rightX + headWidth - halfHeadWidth
|
|
256
|
+
let tailY = ribbonHeight
|
|
257
|
+
let tailHeight = padding
|
|
258
|
+
|
|
259
|
+
context.fill(
|
|
260
|
+
Path(CGRect(x: tailStartX, y: tailY, width: halfHeadWidth, height: halfHeadWidth)),
|
|
261
|
+
with: .color(mainColor)
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
let tailRightPath = RoundedCorners(topLeft: 0, topRight: tailHeight, bottomLeft: 0, bottomRight: tailHeight)
|
|
265
|
+
.path(in: CGRect(x: tailStartX, y: tailY, width: halfHeadWidth, height: tailHeight))
|
|
266
|
+
context.fill(tailRightPath, with: .color(tailColor))
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
MomoText(text, typography: .labelXsMedium, color: Colors.black01, maxLines: 1)
|
|
270
|
+
.padding(.bottom, padding)
|
|
271
|
+
.padding(.horizontal, padding / 2)
|
|
272
|
+
.scaleEffect(x: scale.x, y: scale.y)
|
|
273
|
+
}
|
|
274
|
+
.frame(width: badgeWidth, height: badgeHeight)
|
|
275
|
+
.scaleEffect(x: scale.x, y: scale.y)
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
199
279
|
// MARK: - Constants
|
|
200
280
|
private struct RibbonConstants {
|
|
201
281
|
static let ribbonHeight: CGFloat = 20
|
package/ios/Button/Button.swift
CHANGED
|
@@ -39,7 +39,7 @@ public extension ButtonType {
|
|
|
39
39
|
case .disabled: return theme.colors.background.disable.opacity(loading ? 0.75 : 1)
|
|
40
40
|
case .primary: return theme.colors.primary.opacity(loading ? 0.75 : 1)
|
|
41
41
|
case .secondary: return theme.colors.background.surface.opacity(loading ? 0.75 : 1)
|
|
42
|
-
case .outline: return theme.colors.background.surface.opacity(loading ? 0.75 : 1)
|
|
42
|
+
case .outline: return theme.colors.background.surface.opacity(loading ? 0.75 : 1)
|
|
43
43
|
case .tonal: return theme.colors.background.tonal.opacity(loading ? 0.75 : 1)
|
|
44
44
|
case .danger: return theme.colors.error.primary.opacity(loading ? 0.75 : 1)
|
|
45
45
|
case .text: return .clear
|
|
@@ -123,7 +123,6 @@ extension ButtonSize {
|
|
|
123
123
|
case .large: return .actionDefaultBold
|
|
124
124
|
case .medium: return .actionSBold
|
|
125
125
|
case .small: return .actionXsBold
|
|
126
|
-
default : return .actionDefaultBold
|
|
127
126
|
}
|
|
128
127
|
}
|
|
129
128
|
}
|
|
@@ -162,6 +161,35 @@ public struct Button: View {
|
|
|
162
161
|
self.useTintColor = useTintColor
|
|
163
162
|
}
|
|
164
163
|
|
|
164
|
+
/// Mirrors Kotlin's `Button(iconLeft: String, iconRight: String, ...)`, which resolves
|
|
165
|
+
/// icons by design-system source name through the shared `Icon` component rather than
|
|
166
|
+
/// a caller-supplied view. `iconLeft` is required here (unlike Kotlin's `= ""` default)
|
|
167
|
+
/// solely to keep this initializer unambiguous alongside the `AnyView`-based one above —
|
|
168
|
+
/// pass `""` explicitly for an icon-right-only button.
|
|
169
|
+
public init(
|
|
170
|
+
title: String = "Button",
|
|
171
|
+
action: @escaping () -> Void,
|
|
172
|
+
type: ButtonType = .primary,
|
|
173
|
+
size: ButtonSize = .large,
|
|
174
|
+
iconLeft: String,
|
|
175
|
+
iconRight: String = "",
|
|
176
|
+
isFull: Bool = true,
|
|
177
|
+
loading: Bool = false,
|
|
178
|
+
useTintColor: Bool = true
|
|
179
|
+
) {
|
|
180
|
+
self.init(
|
|
181
|
+
title: title,
|
|
182
|
+
action: action,
|
|
183
|
+
type: type,
|
|
184
|
+
size: size,
|
|
185
|
+
iconLeft: iconLeft.isEmpty ? nil : AnyView(Icon(source: iconLeft)),
|
|
186
|
+
iconRight: iconRight.isEmpty ? nil : AnyView(Icon(source: iconRight)),
|
|
187
|
+
isFull: isFull,
|
|
188
|
+
loading: loading,
|
|
189
|
+
useTintColor: useTintColor
|
|
190
|
+
)
|
|
191
|
+
}
|
|
192
|
+
|
|
165
193
|
private func shouldLoadingOnLeft(_ left: AnyView?, _ right: AnyView?) -> Bool {
|
|
166
194
|
let hasLeft = left != nil
|
|
167
195
|
let hasRight = right != nil
|
|
@@ -214,14 +242,15 @@ public struct Button: View {
|
|
|
214
242
|
.frame(maxWidth: isFull ? .infinity : nil)
|
|
215
243
|
.padding(.horizontal, size.padding)
|
|
216
244
|
.frame(height: size.height)
|
|
217
|
-
.background(bg)
|
|
218
|
-
.overlay(
|
|
219
|
-
RoundedRectangle(cornerRadius: size.radius)
|
|
220
|
-
.strokeBorder(border ?? .clear, lineWidth: border != nil ? borderWidth : 0)
|
|
221
|
-
)
|
|
222
|
-
.clipShape(RoundedRectangle(cornerRadius: size.radius))
|
|
223
245
|
}
|
|
224
|
-
.buttonStyle(ButtonPressFeedbackStyle(
|
|
246
|
+
.buttonStyle(ButtonPressFeedbackStyle(
|
|
247
|
+
isEnabled: isEnabled,
|
|
248
|
+
background: bg,
|
|
249
|
+
border: border,
|
|
250
|
+
borderWidth: borderWidth,
|
|
251
|
+
radius: size.radius,
|
|
252
|
+
height: size.height
|
|
253
|
+
))
|
|
225
254
|
.disabled(!isEnabled)
|
|
226
255
|
}
|
|
227
256
|
|
|
@@ -238,12 +267,39 @@ public struct Button: View {
|
|
|
238
267
|
}
|
|
239
268
|
}
|
|
240
269
|
|
|
270
|
+
/// Mirrors Compose's press feedback: the background shrinks via an animated
|
|
271
|
+
/// horizontal inset while the content fades, rather than a plain opacity dim on
|
|
272
|
+
/// the whole button (matching `animatedPadding`/`alpha` in Button.kt).
|
|
241
273
|
private struct ButtonPressFeedbackStyle: ButtonStyle {
|
|
242
274
|
let isEnabled: Bool
|
|
275
|
+
let background: Color
|
|
276
|
+
let border: Color?
|
|
277
|
+
let borderWidth: CGFloat
|
|
278
|
+
let radius: CGFloat
|
|
279
|
+
let height: CGFloat
|
|
243
280
|
|
|
244
281
|
func makeBody(configuration: Configuration) -> some View {
|
|
245
|
-
configuration.
|
|
246
|
-
|
|
247
|
-
|
|
282
|
+
let pressed = configuration.isPressed && isEnabled
|
|
283
|
+
|
|
284
|
+
ZStack {
|
|
285
|
+
RoundedRectangle(cornerRadius: radius)
|
|
286
|
+
.fill(background)
|
|
287
|
+
.overlay(
|
|
288
|
+
RoundedRectangle(cornerRadius: radius)
|
|
289
|
+
.strokeBorder(border ?? .clear, lineWidth: border != nil ? borderWidth : 0)
|
|
290
|
+
)
|
|
291
|
+
.padding(.horizontal, pressed ? 2 : 0)
|
|
292
|
+
|
|
293
|
+
configuration.label
|
|
294
|
+
.opacity(pressed ? 0.5 : 1)
|
|
295
|
+
}
|
|
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)
|
|
248
304
|
}
|
|
249
305
|
}
|
|
@@ -94,12 +94,27 @@ public struct Carousel<Content: View>: View {
|
|
|
94
94
|
// Offset so the current page is centered within the viewport padding.
|
|
95
95
|
let baseOffset = -CGFloat(currentPage) * step + contentPadding
|
|
96
96
|
|
|
97
|
+
// Windows the rendered range around `currentPage` instead of instantiating all
|
|
98
|
+
// `virtualPageCount` slides up front — with `loop=true`, that's `itemCount * 1000`
|
|
99
|
+
// eager views, a real memory/perf regression versus Compose's lazy `HorizontalPager`.
|
|
100
|
+
// Clear spacers of the skipped width keep the HStack's total layout size (and thus
|
|
101
|
+
// the existing `baseOffset` math) unchanged.
|
|
102
|
+
let windowRadius = 3
|
|
103
|
+
let windowStart = max(0, currentPage - windowRadius)
|
|
104
|
+
let windowEnd = min(virtualPageCount - 1, currentPage + windowRadius)
|
|
105
|
+
|
|
97
106
|
ZStack {
|
|
98
107
|
HStack(spacing: 0) {
|
|
99
|
-
|
|
108
|
+
if windowStart > 0 {
|
|
109
|
+
Color.clear.frame(width: CGFloat(windowStart) * step)
|
|
110
|
+
}
|
|
111
|
+
ForEach(windowStart...windowEnd, id: \.self) { page in
|
|
100
112
|
slide(page: page, step: step, viewportWidth: viewportWidth)
|
|
101
113
|
.frame(width: pageWidth)
|
|
102
114
|
}
|
|
115
|
+
if windowEnd < virtualPageCount - 1 {
|
|
116
|
+
Color.clear.frame(width: CGFloat(virtualPageCount - 1 - windowEnd) * step)
|
|
117
|
+
}
|
|
103
118
|
}
|
|
104
119
|
.offset(x: baseOffset + dragOffset)
|
|
105
120
|
.animation(isDragging ? nil : .easeInOut(duration: 0.25), value: currentPage)
|