@momo-kits/native-kits 0.162.44 → 0.162.45

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.
Files changed (128) hide show
  1. package/CLAUDE.md +45 -3
  2. package/README.md +2 -2
  3. package/ios/Application/ApplicationEnvironment.swift +17 -0
  4. package/ios/Application/MaxApi.swift +30 -3
  5. package/ios/Avatar/Avatar.swift +4 -3
  6. package/ios/Badge/Badge.swift +6 -4
  7. package/ios/Badge/BadgeDot.swift +1 -1
  8. package/ios/Badge/BadgeRibbon.swift +0 -1
  9. package/ios/Button/Button.swift +15 -12
  10. package/ios/CalculatorKeyboard/CalculatorKeyboard.swift +64 -18
  11. package/ios/Carousel/Carousel.swift +29 -12
  12. package/ios/Checkbox/Checkbox.swift +7 -3
  13. package/ios/Chip/Chip.swift +5 -4
  14. package/ios/Collapse/Collapse.swift +70 -19
  15. package/ios/Colors+Radius+Spacing/Colors.swift +10 -1
  16. package/ios/Colors+Radius+Spacing/Radius.swift +6 -0
  17. package/ios/DateTimePicker/DateTimePicker.swift +54 -20
  18. package/ios/DateTimePicker/WheelPicker.swift +103 -105
  19. package/ios/Divider/Divider.swift +37 -4
  20. package/ios/Extensions/Color++.swift +38 -9
  21. package/ios/Extensions/OptionalEnvironmentObject.swift +12 -0
  22. package/ios/Extensions/SkeletonUICompat.swift +11 -0
  23. package/ios/Icon/Icon.swift +21 -23
  24. package/ios/Icon/Icons.swift +1329 -0
  25. package/ios/IconButton/IconButton.swift +15 -7
  26. package/ios/Image/Image.swift +7 -23
  27. package/ios/Information/Information.swift +27 -9
  28. package/ios/Input/ErrorView.swift +2 -2
  29. package/ios/Input/Input.swift +160 -147
  30. package/ios/Input/InputPhoneNumber.swift +12 -9
  31. package/ios/Input/InputSearch.swift +107 -77
  32. package/ios/Input/InputTextArea.swift +128 -145
  33. package/ios/InputDropDown/InputDropDown.swift +10 -7
  34. package/ios/InputMoney/InputMoney.swift +33 -11
  35. package/ios/InputOTP/InputOTP.swift +22 -12
  36. package/ios/Layout/Card.swift +50 -0
  37. package/ios/Layout/GridContext.swift +187 -3
  38. package/ios/Layout/Item.swift +5 -4
  39. package/ios/Layout/ScreenLayout.swift +385 -0
  40. package/ios/Layout/Section.swift +4 -100
  41. package/ios/Loader/Loader.swift +5 -9
  42. package/ios/Lottie/LottieView.swift +14 -2
  43. package/ios/{Application/Navigation/Overplay → Navigation}/BottomSheet.swift +169 -29
  44. package/ios/Navigation/BottomTab/BottomTab.swift +332 -0
  45. package/ios/{Application/Navigation → Navigation}/BottomTab/BottomTabBar.swift +18 -7
  46. package/ios/Navigation/BottomTab/DuoBarCompression.swift +104 -0
  47. package/ios/Navigation/BottomTab/DuoTabBar.swift +329 -0
  48. package/ios/{Application → Navigation/Component}/FloatingButton.swift +43 -54
  49. package/ios/Navigation/Component/Header.swift +545 -0
  50. package/ios/Navigation/Component/HeaderBackground.swift +154 -0
  51. package/ios/Navigation/Component/HeaderJourney.swift +73 -0
  52. package/ios/Navigation/Component/HeaderLocation.swift +48 -0
  53. package/ios/Navigation/Component/HeaderRight.swift +425 -0
  54. package/ios/Navigation/Component/HeaderTitle.swift +36 -0
  55. package/ios/{Application/Navigation → Navigation/Component}/HeaderUser.swift +21 -24
  56. package/ios/Navigation/Component/SideBar.swift +183 -0
  57. package/ios/{Application/Navigation/Overplay → Navigation/Component}/SnackBar.swift +8 -8
  58. package/ios/Navigation/HostBackGestureBridge.swift +78 -0
  59. package/ios/{Application/Navigation/Overplay → Navigation}/ModalScreen.swift +9 -2
  60. package/ios/Navigation/Navigation.swift +204 -0
  61. package/ios/Navigation/NavigationContainer.swift +195 -0
  62. package/ios/{Application/Navigation → Navigation}/Navigator.swift +207 -107
  63. package/ios/Navigation/StackScreen.swift +930 -0
  64. package/ios/Navigation/StackTransition.swift +400 -0
  65. package/ios/Navigation/SwipeBackHaptic.swift +33 -0
  66. package/ios/{Application/Navigation → Navigation}/Tracking/ScreenTracker.swift +12 -5
  67. package/ios/OTPKeyboard/OTPKeyboard.swift +8 -11
  68. package/ios/Pagination/PaginationScroll.swift +2 -2
  69. package/ios/Popup/PopupDisplay.swift +72 -135
  70. package/ios/Popup/PopupInput.swift +10 -13
  71. package/ios/Popup/PopupLayout.swift +140 -0
  72. package/ios/Popup/PopupNotify.swift +59 -69
  73. package/ios/Popup/PopupPromotion.swift +31 -47
  74. package/ios/PopupView/FullscreenPopup.swift +13 -19
  75. package/ios/PopupView/PopupView.swift +1 -1
  76. package/ios/PopupView/Utils++.swift +0 -8
  77. package/ios/ProgressInfo/ProgressInfo.swift +4 -2
  78. package/ios/Radio/Radio.swift +2 -1
  79. package/ios/Rating/Rating.swift +9 -1
  80. package/ios/Resources/dot_loading.json +558 -0
  81. package/ios/Resources/lottie_circle_loader.json +1 -0
  82. package/ios/ScrollIndicator/ScrollIndicator.swift +11 -10
  83. package/ios/Skeleton/Skeleton.swift +22 -27
  84. package/ios/Slider/Slider.swift +16 -18
  85. package/ios/Stepper/Stepper.swift +6 -1
  86. package/ios/Steps/Steps.swift +8 -6
  87. package/ios/SuggestAction/SuggestAction.swift +5 -0
  88. package/ios/Swipeable/SwipeCell.swift +10 -6
  89. package/ios/Switch/Switch.swift +22 -20
  90. package/ios/TabView/TabView.swift +102 -21
  91. package/ios/Tag/Tag.swift +15 -4
  92. package/ios/Template/Logo/Logo.swift +2 -1
  93. package/ios/Template/TrustBanner/TrustBanner.swift +106 -54
  94. package/ios/Title/Title.swift +6 -17
  95. package/ios/Tooltip/Tooltip.swift +29 -14
  96. package/ios/Typography/FontScale.swift +3 -3
  97. package/ios/Typography/Text.swift +81 -19
  98. package/ios/Uploader/Uploader.swift +3 -1
  99. package/ios/native-kits.podspec +2 -1
  100. package/ios-demo/MoMoUIKitsDemo.podspec +1 -1
  101. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/ContentView.swift +1 -1
  102. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/DuoTabDemo.swift +80 -0
  103. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/Home.swift +2 -0
  104. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/IPhoneDuoDemo.swift +173 -0
  105. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/NavigationDemo.swift +77 -1
  106. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PopupDisplayDemo.swift +1 -0
  107. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PopupNotifyDemo.swift +2 -2
  108. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PopupPromotionDemo.swift +2 -0
  109. package/package.json +1 -1
  110. package/scripts/gen-ios-icons.mjs +36 -0
  111. package/ios/Application/Components.swift +0 -270
  112. package/ios/Application/ComposeApi.swift +0 -22
  113. package/ios/Application/HeaderRight.swift +0 -306
  114. package/ios/Application/Navigation/BottomTab/BottomTab.swift +0 -104
  115. package/ios/Application/Navigation/HeaderBackProps.swift +0 -66
  116. package/ios/Application/Navigation/HeaderTitle.swift +0 -57
  117. package/ios/Application/Navigation/NavigationContainer.swift +0 -213
  118. package/ios/Application/Navigation/NavigationOptions.swift +0 -109
  119. package/ios/Application/Navigation/NavigatorManager.swift +0 -22
  120. package/ios/Application/Navigation/component/Header.swift +0 -178
  121. package/ios/Application/Navigation/component/HeaderBackground.swift +0 -83
  122. package/ios/Application/Navigation/component/HeaderColor.swift +0 -65
  123. package/ios/Application/Navigation/component/HeaderRightWidthKey.swift +0 -11
  124. package/ios/Application/Navigation/component/NavigationFooter.swift +0 -90
  125. package/ios/Application/Navigation/component/NavigationHeaderRight.swift +0 -196
  126. package/ios/Application/Screen.swift +0 -250
  127. package/ios/Application/StackScreen.swift +0 -492
  128. /package/ios/{Application/Navigation → Navigation}/BottomTab/CurvedContainer.swift +0 -0
@@ -0,0 +1,329 @@
1
+ //
2
+ // DuoTabBar.swift
3
+ // MoMoUIKits
4
+ //
5
+ // iPhone Duo tab bar, port of RN `BottomTab/DuoTabBar.tsx` (Figma Fold-Design-System MT1/MT2):
6
+ // a rail in the side bar, or a floating bar when unfolded portrait.
7
+ //
8
+
9
+ import SwiftUI
10
+
11
+ private let itemSize = DuoMetrics.controlSize
12
+ private let iconSize: CGFloat = 28
13
+ private let qrIconSize: CGFloat = 24
14
+ private let railPadding: CGFloat = 6
15
+ private let railGap = Spacing.XXS
16
+ // selection bubble overflows the 48 rail cell by 5 above and below (Figma MT2)
17
+ private let railBubbleWidth: CGFloat = 44
18
+ private let railBubbleHeight: CGFloat = 58
19
+ private let floatingHeight = DuoMetrics.floatingTabBarHeight
20
+ private let floatingPadding = Spacing.XS
21
+ // the system bar sizes its pill to the tabs instead of stretching it across the display
22
+ private let floatingItemWidth: CGFloat = 80
23
+ // while pressed the selection bubble grows into a lens past the pill's edges
24
+ private let lensScaleX: CGFloat = 1.2
25
+ private let lensScaleY: CGFloat = 1.3
26
+ // Figma Home-2026 tab bar "Selection": brand pink at 8%
27
+ private let selectionTintOpacity: Double = 0x14 / 255
28
+ private let tabSpring = Animation.interpolatingSpring(mass: 0.8, stiffness: 220, damping: 18)
29
+
30
+ /// Room tab screens leave for the Duo tab bar, provided by BottomTab to its tab StackScreens.
31
+ struct DuoTabBarInsets: Equatable {
32
+ /// Floating bar overlays the content: scroll content, footer and FAB keep clear of it.
33
+ var overlay: CGFloat = 0
34
+ /// Rail sits at the bottom of the side strip: the header side bar stays this far above the window bottom.
35
+ var sideBarReserve: CGFloat = 0
36
+ }
37
+
38
+ private struct DuoTabBarInsetsKey: EnvironmentKey {
39
+ static let defaultValue = DuoTabBarInsets()
40
+ }
41
+
42
+ extension EnvironmentValues {
43
+ var duoTabBarInsets: DuoTabBarInsets {
44
+ get { self[DuoTabBarInsetsKey.self] }
45
+ set { self[DuoTabBarInsetsKey.self] = newValue }
46
+ }
47
+ }
48
+
49
+ /// Rail plus the QR button under it; compressed, the rail is a single button.
50
+ func duoRailHeight(itemCount: Int, hasQr: Bool, compressed: Bool = false) -> CGFloat {
51
+ let count = CGFloat(itemCount)
52
+ let rail = compressed ? itemSize : railPadding * 2 + count * itemSize + max(count - 1, 0) * railGap
53
+ return rail + (hasQr ? Spacing.M + itemSize : 0)
54
+ }
55
+
56
+ extension ScreenLayout {
57
+ /// Overlaps the home indicator inset like the system tab bar (21pt above the bottom with a 34pt inset).
58
+ var duoFloatingTabBarBottom: CGFloat {
59
+ max(insets.bottom - DuoMetrics.floatingTabBarInsetOverlap, Spacing.S)
60
+ }
61
+
62
+ func duoTabBarInsets(itemCount: Int, hasFloatingButton: Bool, tabBarCompressed: Bool = false) -> DuoTabBarInsets {
63
+ guard isIPhoneDuo else { return DuoTabBarInsets() }
64
+ guard let sideBar else {
65
+ // overlay is measured from the bottom safe area, the bar reaches into it
66
+ return DuoTabBarInsets(overlay: floatingHeight + duoFloatingTabBarBottom - insets.bottom)
67
+ }
68
+ let rail = duoRailHeight(itemCount: itemCount, hasQr: hasFloatingButton, compressed: tabBarCompressed)
69
+ return DuoTabBarInsets(sideBarReserve: sideBar.bottom + rail + DuoBarCompression.groupGap)
70
+ }
71
+ }
72
+
73
+ /// RN `BarMaterial`: surface with a 1pt border.
74
+ struct DuoBarMaterial<S: InsettableShape>: ViewModifier {
75
+ let shape: S
76
+
77
+ @Environment(\.appTheme) private var theme
78
+
79
+ func body(content: Content) -> some View {
80
+ content.background(
81
+ shape.fill(theme.colors.background.surface)
82
+ .overlay(shape.strokeBorder(theme.colors.border.default, lineWidth: 1))
83
+ .allowsHitTesting(false)
84
+ )
85
+ }
86
+ }
87
+
88
+ struct DuoTabBar: View {
89
+ let items: [BottomTabItem]
90
+ let floatingButton: BottomTabFloatingButton?
91
+ let selectedIndex: Int
92
+ let onTabSelected: (Int) -> Void
93
+ var compression = DuoBarCompression()
94
+
95
+ @Environment(\.screenLayout) private var layout
96
+
97
+ var body: some View {
98
+ if let sideBar = layout.sideBar {
99
+ DuoRailTabBar(sideBar: sideBar, items: items, floatingButton: floatingButton,
100
+ selectedIndex: selectedIndex, onTabSelected: onTabSelected, compression: compression)
101
+ } else {
102
+ DuoFloatingTabBar(items: items, floatingButton: floatingButton,
103
+ selectedIndex: selectedIndex, onTabSelected: onTabSelected)
104
+ }
105
+ }
106
+ }
107
+
108
+ /// Vertical bar anchored to the bottom of the side bar, QR button below it (Figma MT1-C/E).
109
+ /// When the side bar is short it can shrink to one button showing the current tab
110
+ /// (HIG "Tab bar compressed"); tapping it expands the rail and compresses the toolbar.
111
+ private struct DuoRailTabBar: View {
112
+ let sideBar: SideBarLayout
113
+ let items: [BottomTabItem]
114
+ let floatingButton: BottomTabFloatingButton?
115
+ let selectedIndex: Int
116
+ let onTabSelected: (Int) -> Void
117
+ let compression: DuoBarCompression
118
+
119
+ @State private var pressedIndex: Int?
120
+
121
+ private var isRight: Bool { sideBar.edge == .right }
122
+
123
+ var body: some View {
124
+ VStack(spacing: Spacing.M) {
125
+ if compression.tabBarCompressed, items.indices.contains(selectedIndex) {
126
+ SwiftUI.Button(action: compression.expandTabBar) {
127
+ DuoTabIcon(item: items[selectedIndex], selected: true)
128
+ .frame(width: itemSize, height: itemSize)
129
+ .contentShape(Circle())
130
+ }
131
+ .buttonStyle(.plain)
132
+ .modifier(DuoBarMaterial(shape: Circle()))
133
+ .accessibilityLabel(items[selectedIndex].label)
134
+ .accessibilityIdentifier("bottom_tab_bar")
135
+ } else {
136
+ rail
137
+ }
138
+
139
+ if let floatingButton {
140
+ DuoQrButton(data: floatingButton, size: itemSize)
141
+ }
142
+ }
143
+ .padding(isRight ? .leading : .trailing, Spacing.M)
144
+ .frame(width: sideBar.width, alignment: isRight ? .leading : .trailing)
145
+ .padding(.bottom, sideBar.bottom)
146
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: isRight ? .bottomTrailing : .bottomLeading)
147
+ .ignoresSafeArea()
148
+ }
149
+
150
+ /// Column of icon-only tab items with the sliding selection bubble.
151
+ private var rail: some View {
152
+ ZStack(alignment: .top) {
153
+ DuoSelectionMaterial(lens: pressedIndex != nil)
154
+ .frame(width: railBubbleWidth, height: railBubbleHeight)
155
+ .offset(y: railPadding - (railBubbleHeight - itemSize) / 2
156
+ + CGFloat(pressedIndex ?? selectedIndex) * (itemSize + railGap))
157
+ .animation(tabSpring, value: selectedIndex)
158
+ .animation(tabSpring, value: pressedIndex)
159
+
160
+ VStack(spacing: railGap) {
161
+ ForEach(items.indices, id: \.self) { index in
162
+ SwiftUI.Button {
163
+ onTabSelected(index)
164
+ compression.onTabSelected()
165
+ } label: {
166
+ DuoTabIcon(item: items[index], selected: index == selectedIndex)
167
+ .frame(width: itemSize, height: itemSize)
168
+ .contentShape(Rectangle())
169
+ }
170
+ .buttonStyle(DuoLensPressStyle(index: index, pressedIndex: $pressedIndex))
171
+ .accessibilityLabel(items[index].label)
172
+ }
173
+ }
174
+ .padding(.vertical, railPadding)
175
+ }
176
+ .frame(width: itemSize)
177
+ .modifier(DuoBarMaterial(shape: Capsule()))
178
+ .accessibilityIdentifier("bottom_tab_bar")
179
+ }
180
+ }
181
+
182
+ /// Floating labelled bar for the unfolded portrait display, QR pinned in its own platter beside it.
183
+ private struct DuoFloatingTabBar: View {
184
+ let items: [BottomTabItem]
185
+ let floatingButton: BottomTabFloatingButton?
186
+ let selectedIndex: Int
187
+ let onTabSelected: (Int) -> Void
188
+
189
+ @Environment(\.screenLayout) private var layout
190
+ @Environment(\.appTheme) private var theme
191
+ @State private var pressedIndex: Int?
192
+
193
+ private var barWidth: CGFloat {
194
+ let margins = layout.insets.leading + layout.insets.trailing + 2 * DuoMetrics.floatingTabBarMargin
195
+ let qr = floatingButton == nil ? 0 : floatingHeight + Spacing.S
196
+ let preferred = CGFloat(items.count) * floatingItemWidth + 2 * floatingPadding
197
+ return max(min(preferred, layout.width - margins - qr), 0)
198
+ }
199
+
200
+ private var cellWidth: CGFloat { (barWidth - floatingPadding * 2) / CGFloat(max(items.count, 1)) }
201
+
202
+ var body: some View {
203
+ HStack(spacing: Spacing.S) {
204
+ bar
205
+ if let floatingButton {
206
+ DuoQrButton(data: floatingButton, size: floatingHeight)
207
+ .shadow(color: Color.black.opacity(0.16), radius: 16, x: 0, y: 6)
208
+ }
209
+ }
210
+ .padding(.bottom, layout.duoFloatingTabBarBottom)
211
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
212
+ .ignoresSafeArea()
213
+ }
214
+
215
+ private var bar: some View {
216
+ ZStack(alignment: .leading) {
217
+ DuoSelectionMaterial(lens: pressedIndex != nil)
218
+ .frame(width: cellWidth)
219
+ .offset(x: CGFloat(pressedIndex ?? selectedIndex) * cellWidth)
220
+ .animation(tabSpring, value: selectedIndex)
221
+ .animation(tabSpring, value: pressedIndex)
222
+
223
+ HStack(spacing: 0) {
224
+ ForEach(items.indices, id: \.self) { index in
225
+ tabCell(index)
226
+ }
227
+ }
228
+ }
229
+ .padding(floatingPadding)
230
+ .frame(width: barWidth, height: floatingHeight)
231
+ .modifier(DuoBarMaterial(shape: Capsule()))
232
+ .accessibilityIdentifier("bottom_tab_bar")
233
+ }
234
+
235
+ private func tabCell(_ index: Int) -> some View {
236
+ let selected = index == selectedIndex
237
+ return SwiftUI.Button { onTabSelected(index) } label: {
238
+ VStack(spacing: Spacing.XXS) {
239
+ DuoTabIcon(item: items[index], selected: selected)
240
+ MomoText(
241
+ items[index].label,
242
+ typography: .labelXsMedium,
243
+ color: selected ? theme.colors.primary : theme.colors.text.hint,
244
+ textAlign: .center,
245
+ maxLines: 1
246
+ )
247
+ // like the system tab bar the label keeps its design size; a constant snapshot, nothing to observe
248
+ .environment(\.momoFontScale, FontScale(userScaleRate: 1, useOSScaleRate: false))
249
+ }
250
+ .frame(width: cellWidth)
251
+ .frame(maxHeight: .infinity)
252
+ .contentShape(Rectangle())
253
+ }
254
+ .buttonStyle(DuoLensPressStyle(index: index, pressedIndex: $pressedIndex))
255
+ .accessibilityLabel(items[index].label)
256
+ }
257
+ }
258
+
259
+ /// Pink tinted capsule under the selected tab; while pressed it grows into a lens (80x54 to about 96x70).
260
+ private struct DuoSelectionMaterial: View {
261
+ let lens: Bool
262
+
263
+ @Environment(\.appTheme) private var theme
264
+
265
+ var body: some View {
266
+ Capsule()
267
+ .fill(theme.colors.primary.opacity(selectionTintOpacity))
268
+ .scaleEffect(x: lens ? lensScaleX : 1, y: lens ? lensScaleY : 1)
269
+ .allowsHitTesting(false)
270
+ }
271
+ }
272
+
273
+ /// Tracks the finger so the selection lens jumps to the pressed tab before it is selected.
274
+ private struct DuoLensPressStyle: ButtonStyle {
275
+ let index: Int
276
+ @Binding var pressedIndex: Int?
277
+
278
+ func makeBody(configuration: Configuration) -> some View {
279
+ configuration.label.onChange(of: configuration.isPressed) { pressed in
280
+ if pressed {
281
+ pressedIndex = index
282
+ } else if pressedIndex == index {
283
+ pressedIndex = nil
284
+ }
285
+ }
286
+ }
287
+ }
288
+
289
+ private struct DuoTabIcon: View {
290
+ let item: BottomTabItem
291
+ let selected: Bool
292
+
293
+ @Environment(\.appTheme) private var theme
294
+
295
+ private var badgeLabel: String? {
296
+ guard let label = item.badgeLabel else { return nil }
297
+ if let n = Int(label), n == 0 { return nil }
298
+ return label
299
+ }
300
+
301
+ var body: some View {
302
+ Icon(source: item.icon, size: iconSize, color: selected ? theme.colors.primary : theme.colors.text.hint)
303
+ .overlay(alignment: .topTrailing) {
304
+ if let badgeLabel, !badgeLabel.isEmpty {
305
+ Badge(label: badgeLabel).offset(x: Spacing.S, y: -Spacing.XS)
306
+ } else if badgeLabel != nil || item.showDot {
307
+ BadgeDot(size: .small)
308
+ }
309
+ }
310
+ }
311
+ }
312
+
313
+ private struct DuoQrButton: View {
314
+ let data: BottomTabFloatingButton
315
+ let size: CGFloat
316
+
317
+ @Environment(\.appTheme) private var theme
318
+
319
+ var body: some View {
320
+ SwiftUI.Button(action: data.onPress) {
321
+ Circle()
322
+ .fill(theme.colors.primary)
323
+ .frame(width: size, height: size)
324
+ .overlay(Icon(source: data.icon, size: qrIconSize, color: Colors.black01))
325
+ }
326
+ .buttonStyle(.plain)
327
+ .accessibilityLabel(data.label)
328
+ }
329
+ }
@@ -1,5 +1,12 @@
1
+ //
2
+ // FloatingButton.swift
3
+ // MoMoUIKits
4
+ //
5
+ // SwiftUI mirror of Compose's `navigation/component/FloatingButton.kt`: the screen FAB, whose
6
+ // label collapses while the content scrolls down.
7
+ //
8
+
1
9
  import SwiftUI
2
- import Combine
3
10
 
4
11
  public enum FABSize {
5
12
  case small
@@ -58,14 +65,10 @@ public struct FabProps {
58
65
  }
59
66
 
60
67
  public struct FloatingButton: View {
68
+ @State private var isExpanded: Bool = true
61
69
  @State private var lastScrollOffset: CGFloat = 0
62
- @State private var isExpanded: Bool = false
63
- @State private var internalScrollOffset: CGFloat = 0
64
- @State private var showText: Bool = true
65
- @State private var fullWidth: CGFloat? = nil
66
- @State private var currentWidth: CGFloat = 0
67
- @State private var lastDirection: String? = nil
68
-
70
+ @State private var settleScroll: Task<Void, Never>?
71
+
69
72
  private let props: FabProps
70
73
  private var keyboardOffset: CGFloat
71
74
  private var scrollOffset: CGFloat
@@ -111,64 +114,50 @@ public struct FloatingButton: View {
111
114
  content
112
115
  }
113
116
  }
114
- .onTapGesture {
115
- onClick?()
116
- }
117
117
  .padding(Spacing.M)
118
118
  .frame(height: height + (Spacing.M * 2))
119
119
  .background(containerColor)
120
120
  .cornerRadius(100)
121
+ // Compose FloatingActionButton elevation 4dp
122
+ .shadow(color: Color.black.opacity(0.2), radius: 4, x: 0, y: 2)
123
+ .contentShape(Capsule())
124
+ .onTapGesture {
125
+ onClick?()
126
+ }
127
+ .accessibilityElement(children: .combine)
128
+ .accessibilityAddTraits(.isButton)
121
129
  .padding(.bottom, bottomPadding)
122
130
  .frame(alignment: position == .center ? .center : .trailing)
123
- .onAppear {
124
- internalScrollOffset = scrollOffset
125
- isExpanded = label != nil
126
- }
127
- .onReceive(Just(scrollOffset)) { newValue in
128
- let direction = newValue > internalScrollOffset ? "down" : "up"
131
+ .onAppear { lastScrollOffset = scrollOffset }
132
+ .onChange(of: scrollOffset, perform: updateExpanded)
133
+ .onDisappear { settleScroll?.cancel() }
134
+ }
129
135
 
130
- if direction != lastDirection {
131
- lastDirection = direction
132
- if direction == "down" {
133
- withAnimation(.easeOut(duration: 0.1)) {
134
- isExpanded = false
135
- }
136
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
137
- showText = false
138
- }
139
- } else {
140
- showText = true
141
- withAnimation(.easeIn(duration: 0.1)) {
142
- isExpanded = true
143
- }
144
-
145
- }
146
- }
136
+ /// Compose's `collectLatest { isExpanded = new <= last; delay(100); last = new }`: the label
137
+ /// shows while the content scrolls back up, and the reference only moves once the scroll pauses.
138
+ private func updateExpanded(_ offset: CGFloat) {
139
+ let expanded = offset <= lastScrollOffset
140
+ if expanded != isExpanded {
141
+ withAnimation(.linear(duration: 0.1)) { isExpanded = expanded }
142
+ }
143
+ settleScroll?.cancel()
144
+ settleScroll = Task { @MainActor in
145
+ try? await Task.sleep(nanoseconds: 100_000_000)
146
+ guard !Task.isCancelled else { return }
147
+ lastScrollOffset = offset
147
148
  }
148
149
  }
149
-
150
+
150
151
  private var content: some View {
151
152
  HStack(spacing: 0) {
152
- ImageView(icon ?? "")
153
- .scaledToFit()
154
- .frame(width: iconSize, height: iconSize)
155
- .foregroundColor(iconColor)
156
-
157
- if let label = label {
158
- if showText {
159
- Spacer().frame(width: 8)
160
- MomoText(label, typography: .actionDefaultBold, color: .white, maxLines: 1)
161
- .background(
162
- GeometryReader { geo in
163
- Color.clear.onAppear {
164
- fullWidth = geo.size.width
165
- }
166
- }
167
- )
168
- .opacity(isExpanded ? 1 : 0)
169
- }
170
- }
153
+ // Compose `Icon`: an icon key or an https URL, tinted
154
+ Icon(source: icon ?? "", size: iconSize ?? defaultIconSize, color: iconColor)
171
155
 
156
+ if let label, isExpanded {
157
+ MomoText(label, typography: .actionDefaultBold, color: .white, maxLines: 1)
158
+ .padding(.leading, 12)
159
+ .transition(.opacity.combined(with: .move(edge: .leading)))
160
+ }
172
161
  }
173
162
  }
174
163
  }