@momo-kits/native-kits 0.168.1-beta.2-debug → 0.168.1-beta.24-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.md +10 -6
- package/compose/build.gradle.kts +1 -1
- package/compose/compose.podspec +1 -1
- package/compose/src/androidMain/kotlin/vn/momo/kits/const/HostFont.android.kt +4 -3
- package/compose/src/androidMain/kotlin/vn/momo/kits/platform/ReservedRegions.android.kt +1 -1
- package/compose/src/commonMain/kotlin/vn/momo/kits/application/Context.kt +0 -14
- package/compose/src/commonMain/kotlin/vn/momo/kits/application/DesignSystemKits.kt +62 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/PopupNotify.kt +25 -16
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/PopupPromotion.kt +3 -3
- package/compose/src/commonMain/kotlin/vn/momo/kits/const/HostFont.kt +2 -1
- package/compose/src/commonMain/kotlin/vn/momo/kits/const/Typography.kt +18 -14
- package/compose/src/commonMain/kotlin/vn/momo/kits/layout/ScreenLayout.kt +25 -13
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/BottomSheet.kt +206 -45
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/ModalScreen.kt +14 -3
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/NavigationContainer.kt +2 -2
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigator.kt +53 -30
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/DuoBarCompression.kt +2 -3
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/DuoTabBar.kt +124 -59
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/Header.kt +1 -1
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderBackground.kt +3 -1
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderTitle.kt +2 -2
- package/compose/src/commonMain/kotlin/vn/momo/kits/platform/ReservedRegions.kt +10 -3
- package/compose/src/iosMain/kotlin/vn/momo/kits/const/HostFont.ios.kt +3 -1
- package/compose/src/iosMain/kotlin/vn/momo/kits/platform/Platform.ios.kt +9 -8
- package/compose/src/iosMain/kotlin/vn/momo/kits/platform/ReservedRegions.ios.kt +54 -10
- package/gradle.properties +1 -1
- package/ios/Application/ApplicationEnvironment.swift +7 -6
- package/ios/Application/DesignSystemKits.swift +163 -0
- package/ios/Application/DesignSystemKitsPayload.swift +68 -0
- package/ios/Badge/BadgeRibbon.swift +8 -1
- package/ios/Extensions/Color++.swift +2 -4
- package/ios/Input/Input.swift +13 -3
- package/ios/Input/InputPhoneNumber.swift +1 -3
- package/ios/Input/InputSearch.swift +1 -2
- package/ios/Input/InputTextArea.swift +5 -3
- package/ios/InputMoney/InputMoney.swift +6 -7
- package/ios/Layout/ScreenLayout.swift +65 -10
- package/ios/Navigation/BottomSheet.swift +153 -23
- package/ios/Navigation/BottomTab/DuoBarCompression.swift +2 -3
- package/ios/Navigation/BottomTab/DuoTabBar.swift +94 -69
- package/ios/Navigation/Component/HeaderBackground.swift +22 -1
- package/ios/Navigation/Component/SnackBar.swift +4 -0
- package/ios/Navigation/ModalScreen.swift +7 -3
- package/ios/Navigation/StackScreen.swift +3 -7
- package/ios/Popup/PopupDisplay.swift +2 -2
- package/ios/Popup/PopupInput.swift +2 -2
- package/ios/Popup/PopupLayout.swift +15 -10
- package/ios/Popup/PopupNotify.swift +2 -2
- package/ios/Stepper/Stepper.swift +1 -2
- package/ios/Template/TrustBanner/TrustBanner.swift +4 -2
- package/ios/Typography/FontScale.swift +29 -15
- package/ios/Typography/Text.swift +86 -12
- package/ios/Typography/Typography.swift +2 -2
- package/ios/native-kits.podspec +1 -1
- package/ios-demo/MoMoUIKitsDemo.podspec +1 -1
- package/local.properties +8 -0
- package/package.json +1 -1
- package/.claude/settings.local.json +0 -9
- package/compose/src/commonMain/kotlin/vn/momo/kits/const/KitDesignSystem.kt +0 -18
- package/compose/src/commonMain/kotlin/vn/momo/kits/const/KitFontScale.kt +0 -22
- package/ios/Typography/FontScaleStore.swift +0 -58
- package/ios/Typography/KitDesignSystem.swift +0 -17
|
@@ -15,16 +15,25 @@ import SwiftUI
|
|
|
15
15
|
|
|
16
16
|
/// Mirrors Compose's `sealed class BottomHeader` (Title / Custom).
|
|
17
17
|
public struct BottomSheetHeader {
|
|
18
|
+
struct Back {
|
|
19
|
+
let icon: String
|
|
20
|
+
let action: () -> Void
|
|
21
|
+
}
|
|
22
|
+
|
|
18
23
|
enum Style {
|
|
19
|
-
case title(String)
|
|
24
|
+
case title(String, back: Back?)
|
|
20
25
|
case custom(() -> AnyView)
|
|
21
26
|
}
|
|
22
27
|
|
|
23
28
|
let style: Style
|
|
24
29
|
|
|
25
|
-
/// Mirrors `BottomHeader.Title`.
|
|
26
|
-
public static func title(
|
|
27
|
-
|
|
30
|
+
/// Mirrors `BottomHeader.Title`. `onBack` shows a back button; the sheet closes, then it runs.
|
|
31
|
+
public static func title(
|
|
32
|
+
_ text: String = "Bottom Sheet Title",
|
|
33
|
+
backIcon: String = "arrow-back",
|
|
34
|
+
onBack: (() -> Void)? = nil
|
|
35
|
+
) -> BottomSheetHeader {
|
|
36
|
+
BottomSheetHeader(style: .title(text, back: onBack.map { Back(icon: backIcon, action: $0) }))
|
|
28
37
|
}
|
|
29
38
|
|
|
30
39
|
/// Mirrors `BottomHeader.Custom`.
|
|
@@ -68,6 +77,9 @@ struct BottomSheetView<Content: View>: View {
|
|
|
68
77
|
/// Mirrors Compose's `sheetCloseOffset` (100dp drag threshold to dismiss).
|
|
69
78
|
private let closeThreshold: CGFloat = 100
|
|
70
79
|
|
|
80
|
+
private let indicatorHeight: CGFloat = 16
|
|
81
|
+
private let headerRowHeight: CGFloat = 56
|
|
82
|
+
|
|
71
83
|
/// Mirrors Compose's per-term peak (0.3f). Combined with `backgroundAlpha` the
|
|
72
84
|
/// steady-state (fully open) scrim peaks at 0.6, matching ModalScreen's peak.
|
|
73
85
|
private let maxDynamicAlpha: CGFloat = 0.3
|
|
@@ -77,7 +89,23 @@ struct BottomSheetView<Content: View>: View {
|
|
|
77
89
|
|
|
78
90
|
private var isDuo: Bool { layout.isDuoDevice }
|
|
79
91
|
// iPhone Duo inner display: center the sheet instead of spanning the width
|
|
80
|
-
|
|
92
|
+
// short side, as RN: the outer display stays floating in landscape (678pt wide) too
|
|
93
|
+
private var isDuoCentered: Bool { isDuo && min(layout.width, layout.height) >= 600 }
|
|
94
|
+
/// Outer display: the sheet floats Spacing.M off the side edges like system sheets, its content
|
|
95
|
+
/// stays in the safe area and its buttons move to the side bar column (HIG).
|
|
96
|
+
private var isDuoFloating: Bool { isDuo && !isDuoCentered }
|
|
97
|
+
|
|
98
|
+
private var titleBack: BottomSheetHeader.Back? {
|
|
99
|
+
if case .title(_, let back) = header.style { return back }
|
|
100
|
+
return nil
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private var sideColumn: SideBarLayout? {
|
|
104
|
+
guard isDuoFloating, case .title = header.style, let sideBar = layout.sideBar,
|
|
105
|
+
sideBar.width - Spacing.M >= DuoMetrics.controlSize + Spacing.M
|
|
106
|
+
else { return nil }
|
|
107
|
+
return sideBar
|
|
108
|
+
}
|
|
81
109
|
|
|
82
110
|
/// Keeps the portrait width in landscape, like system sheets.
|
|
83
111
|
private var duoCenteredWidth: CGFloat {
|
|
@@ -87,18 +115,20 @@ struct BottomSheetView<Content: View>: View {
|
|
|
87
115
|
)
|
|
88
116
|
}
|
|
89
117
|
|
|
90
|
-
///
|
|
91
|
-
/// side status bar only covers the top corner.
|
|
118
|
+
/// The floating sheet spans the side safe area, its content stays clear of it.
|
|
92
119
|
private var sideInsetLeading: CGFloat {
|
|
93
|
-
|
|
120
|
+
isDuoFloating ? max(layout.insets.leading - Spacing.M, 0) : 0
|
|
94
121
|
}
|
|
95
122
|
|
|
96
123
|
private var sideInsetTrailing: CGFloat {
|
|
97
|
-
|
|
124
|
+
isDuoFloating ? max(layout.insets.trailing - Spacing.M, 0) : 0
|
|
98
125
|
}
|
|
99
126
|
|
|
100
127
|
private var maxHeight: CGFloat {
|
|
101
|
-
isDuo
|
|
128
|
+
guard isDuo else { return screenHeight - 90 }
|
|
129
|
+
// stays below the side status bar: unlike RN (StatusBar.pushStackEntry) nothing restyles it, so a light
|
|
130
|
+
// clock over the sheet would vanish
|
|
131
|
+
return layout.height - max(layout.duoTopInset, Spacing.XL)
|
|
102
132
|
}
|
|
103
133
|
private var bottomInset: CGFloat { useBottomInset ? safeAreaBottomInset() : 0 }
|
|
104
134
|
/// While the keyboard is up, it already covers the home-indicator area, so it
|
|
@@ -170,19 +200,21 @@ struct BottomSheetView<Content: View>: View {
|
|
|
170
200
|
.frame(width: 40, height: 4)
|
|
171
201
|
Spacer().frame(height: 4)
|
|
172
202
|
headerView
|
|
173
|
-
.frame(height:
|
|
203
|
+
.frame(height: headerRowHeight)
|
|
174
204
|
.padding(.horizontal, Spacing.M)
|
|
175
|
-
.padding(.leading, sideInsetLeading)
|
|
176
|
-
.padding(.trailing, sideInsetTrailing)
|
|
177
205
|
}
|
|
206
|
+
.padding(.leading, sideInsetLeading)
|
|
207
|
+
.padding(.trailing, sideInsetTrailing)
|
|
178
208
|
.frame(maxWidth: .infinity)
|
|
179
|
-
.frame(height:
|
|
209
|
+
.frame(height: indicatorHeight + headerRowHeight)
|
|
180
210
|
.contentShape(Rectangle())
|
|
181
211
|
.gesture(dragGesture, including: draggable ? .all : .subviews)
|
|
182
212
|
|
|
183
213
|
Rectangle()
|
|
184
214
|
.fill(theme.colors.border.default)
|
|
185
215
|
.frame(height: 1)
|
|
216
|
+
.padding(.leading, sideInsetLeading)
|
|
217
|
+
.padding(.trailing, sideInsetTrailing)
|
|
186
218
|
|
|
187
219
|
content()
|
|
188
220
|
.padding(.leading, sideInsetLeading)
|
|
@@ -191,7 +223,7 @@ struct BottomSheetView<Content: View>: View {
|
|
|
191
223
|
.layoutPriority(-1)
|
|
192
224
|
|
|
193
225
|
if let footer {
|
|
194
|
-
BottomSheetFooter(content: footer)
|
|
226
|
+
BottomSheetFooter(content: footer, leadingInset: sideInsetLeading, trailingInset: sideInsetTrailing)
|
|
195
227
|
}
|
|
196
228
|
}
|
|
197
229
|
// The filler stays outside the cap, which shrinks by it instead: the sheet still ends at
|
|
@@ -209,27 +241,61 @@ struct BottomSheetView<Content: View>: View {
|
|
|
209
241
|
}
|
|
210
242
|
)
|
|
211
243
|
.background(backgroundColor)
|
|
212
|
-
.
|
|
244
|
+
.overlay {
|
|
245
|
+
if let sideColumn {
|
|
246
|
+
BottomSheetSideColumn(
|
|
247
|
+
sideBar: sideColumn,
|
|
248
|
+
windowHeight: layout.height,
|
|
249
|
+
topOffset: indicatorHeight + (headerRowHeight - DuoMetrics.controlSize) / 2,
|
|
250
|
+
back: titleBack,
|
|
251
|
+
onBack: goBack,
|
|
252
|
+
onClose: { close() }
|
|
253
|
+
)
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
.clipShape(RoundedCorner(radius: isDuo ? Radius.XL : Radius.M, corners: [.topLeft, .topRight]))
|
|
257
|
+
.padding(.horizontal, isDuoFloating ? Spacing.M : 0)
|
|
213
258
|
}
|
|
214
259
|
|
|
215
260
|
@ViewBuilder
|
|
216
261
|
private var headerView: some View {
|
|
217
262
|
switch header.style {
|
|
218
|
-
case .title(let text):
|
|
263
|
+
case .title(let text, let back):
|
|
264
|
+
// the side column holds back and close, the row keeps only the title
|
|
265
|
+
let showsButtons = sideColumn == nil
|
|
219
266
|
HStack(spacing: 0) {
|
|
220
|
-
|
|
267
|
+
if showsButtons {
|
|
268
|
+
if let back {
|
|
269
|
+
headerButton(icon: back.icon, accessibilityId: "btn_bottom_sheet_back") { goBack(back) }
|
|
270
|
+
} else {
|
|
271
|
+
Color.clear.frame(width: 24, height: 24)
|
|
272
|
+
}
|
|
273
|
+
}
|
|
221
274
|
MomoText(text, typography: .headerDefaultBold, color: Colors.black17)
|
|
222
275
|
.frame(maxWidth: .infinity, alignment: .center)
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
276
|
+
.padding(.horizontal, showsButtons ? 0 : Spacing.M)
|
|
277
|
+
if showsButtons {
|
|
278
|
+
headerButton(icon: "navigation_close", accessibilityId: "btn_bottom_sheet_close") { close() }
|
|
279
|
+
}
|
|
227
280
|
}
|
|
228
281
|
case .custom(let builder):
|
|
229
282
|
builder()
|
|
230
283
|
}
|
|
231
284
|
}
|
|
232
285
|
|
|
286
|
+
private func headerButton(icon: String, accessibilityId: String, action: @escaping () -> Void) -> some View {
|
|
287
|
+
Icon(source: icon, size: 24, color: Colors.black17)
|
|
288
|
+
.frame(width: 24, height: 24)
|
|
289
|
+
.contentShape(Rectangle())
|
|
290
|
+
.onTapGesture(perform: action)
|
|
291
|
+
.accessibilityIdentifier(accessibilityId)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/// RN `leftOptions.onPressIconLeft`: the sheet closes first, then the back runs.
|
|
295
|
+
private func goBack(_ back: BottomSheetHeader.Back) {
|
|
296
|
+
close(then: back.action)
|
|
297
|
+
}
|
|
298
|
+
|
|
233
299
|
// MARK: - Drag handling (mirrors Compose's detectDragGestures)
|
|
234
300
|
|
|
235
301
|
private var dragGesture: some Gesture {
|
|
@@ -282,7 +348,7 @@ struct BottomSheetView<Content: View>: View {
|
|
|
282
348
|
|
|
283
349
|
/// Mirrors Compose's `closeEvent`: slide out over 200ms, then fade the scrim
|
|
284
350
|
/// out over 100ms only after the slide finishes, then dismiss.
|
|
285
|
-
private func close() {
|
|
351
|
+
private func close(then onClosed: (() -> Void)? = nil) {
|
|
286
352
|
guard !isClosing else { return }
|
|
287
353
|
isClosing = true
|
|
288
354
|
withAnimation(.timingCurve(0.3, 0, 0.8, 0.15, duration: 0.2)) {
|
|
@@ -294,7 +360,67 @@ struct BottomSheetView<Content: View>: View {
|
|
|
294
360
|
}
|
|
295
361
|
}
|
|
296
362
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
|
363
|
+
// a newer overlay replaced this one: it stays, and this sheet's back is dropped
|
|
364
|
+
guard navigator.overplay?.id == overplayId else { return }
|
|
297
365
|
navigator.finishOverplayDismiss(id: overplayId)
|
|
366
|
+
onClosed?()
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/// iPhone Duo outer display: back and close sit on the part of the sheet under the side safe area,
|
|
372
|
+
/// 12pt from the strip's inner edge like the screen side bar. They follow the header row while the
|
|
373
|
+
/// sheet is short and stay below the status bar cluster while it is tall.
|
|
374
|
+
private struct BottomSheetSideColumn: View {
|
|
375
|
+
let sideBar: SideBarLayout
|
|
376
|
+
let windowHeight: CGFloat
|
|
377
|
+
/// Lines the controls up with the header row.
|
|
378
|
+
let topOffset: CGFloat
|
|
379
|
+
let back: BottomSheetHeader.Back?
|
|
380
|
+
let onBack: (BottomSheetHeader.Back) -> Void
|
|
381
|
+
let onClose: () -> Void
|
|
382
|
+
|
|
383
|
+
@Environment(\.appTheme) private var theme
|
|
384
|
+
|
|
385
|
+
private var isRight: Bool { sideBar.edge == .right }
|
|
386
|
+
|
|
387
|
+
private var columnHeight: CGFloat {
|
|
388
|
+
let count: CGFloat = back != nil ? 2 : 1
|
|
389
|
+
return count * DuoMetrics.controlSize + (count - 1) * Spacing.M
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
private var headerColor: HeaderColor {
|
|
393
|
+
getHeaderColor(isAnimated: false, isSurface: true, opacity: 1, tintColor: nil, defaultColor: theme.colors.text.default)
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
var body: some View {
|
|
397
|
+
GeometryReader { proxy in
|
|
398
|
+
let sheetHeight = proxy.size.height
|
|
399
|
+
// the sheet rests on the window bottom; its slide is an offset outside this layout
|
|
400
|
+
let sheetTop = windowHeight - sheetHeight
|
|
401
|
+
// clear of a camera at the bottom end of the strip when it can
|
|
402
|
+
let lowest = sheetHeight - sideBar.bottom - columnHeight
|
|
403
|
+
let top = max(min(max(sideBar.top - sheetTop, topOffset), lowest), 0)
|
|
404
|
+
VStack(alignment: isRight ? .leading : .trailing, spacing: Spacing.M) {
|
|
405
|
+
if let back {
|
|
406
|
+
SideBarButton(
|
|
407
|
+
icon: back.icon,
|
|
408
|
+
headerColor: headerColor,
|
|
409
|
+
accessibilityId: "btn_bottom_sheet_back",
|
|
410
|
+
action: { onBack(back) }
|
|
411
|
+
)
|
|
412
|
+
}
|
|
413
|
+
SideBarButton(
|
|
414
|
+
icon: "navigation_close",
|
|
415
|
+
headerColor: headerColor,
|
|
416
|
+
accessibilityId: "btn_bottom_sheet_close",
|
|
417
|
+
action: onClose
|
|
418
|
+
)
|
|
419
|
+
}
|
|
420
|
+
.padding(isRight ? .leading : .trailing, Spacing.M)
|
|
421
|
+
.frame(width: sideBar.width - Spacing.M, alignment: isRight ? .leading : .trailing)
|
|
422
|
+
.padding(.top, top)
|
|
423
|
+
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: isRight ? .topTrailing : .topLeading)
|
|
298
424
|
}
|
|
299
425
|
}
|
|
300
426
|
}
|
|
@@ -371,12 +497,16 @@ private struct CapHeightLayout: Layout {
|
|
|
371
497
|
/// Mirrors Compose's `BottomSheetFooter`: a surface band with a soft shadow cast on the content.
|
|
372
498
|
private struct BottomSheetFooter: View {
|
|
373
499
|
let content: () -> AnyView
|
|
500
|
+
var leadingInset: CGFloat = 0
|
|
501
|
+
var trailingInset: CGFloat = 0
|
|
374
502
|
|
|
375
503
|
@Environment(\.appTheme) private var theme
|
|
376
504
|
|
|
377
505
|
var body: some View {
|
|
378
506
|
content()
|
|
379
507
|
.frame(maxWidth: .infinity)
|
|
508
|
+
.padding(.leading, leadingInset)
|
|
509
|
+
.padding(.trailing, trailingInset)
|
|
380
510
|
.padding(.vertical, Spacing.S)
|
|
381
511
|
.background(theme.colors.background.surface)
|
|
382
512
|
.overlay(alignment: .top) {
|
|
@@ -35,8 +35,6 @@ struct DuoBarCompression {
|
|
|
35
35
|
static let defaultToolbarHeight = DuoMetrics.controlSize + Spacing.M + 2 * DuoMetrics.itemPitch
|
|
36
36
|
/// Back, then the single button the toolbar folds into.
|
|
37
37
|
private static let compressedToolbarHeight = 2 * DuoMetrics.controlSize + Spacing.M
|
|
38
|
-
/// Back alone, while a rail opened from the compressed state takes the toolbar slot.
|
|
39
|
-
private static let backOnlyHeight = DuoMetrics.controlSize
|
|
40
38
|
|
|
41
39
|
/// - both fit: both stay whole
|
|
42
40
|
/// - one fits whole beside the other compressed: one is compressed, tapping it swaps them
|
|
@@ -72,7 +70,8 @@ struct DuoBarCompression {
|
|
|
72
70
|
|
|
73
71
|
let expandToolbar = { opened.wrappedValue = .toolbar }
|
|
74
72
|
let expandTabBar = { opened.wrappedValue = .tabBar }
|
|
75
|
-
|
|
73
|
+
// an opened rail always shows, even when only the group gap is short: it closes once a tab is picked
|
|
74
|
+
if opened.wrappedValue == .tabBar {
|
|
76
75
|
return DuoBarCompression(
|
|
77
76
|
toolbarCompressed: true,
|
|
78
77
|
tabBarCompressed: false,
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// MoMoUIKits
|
|
4
4
|
//
|
|
5
5
|
// iPhone Duo tab bar, port of RN `BottomTab/DuoTabBar.tsx` (Figma Fold-Design-System MT1/MT2):
|
|
6
|
-
// a
|
|
6
|
+
// a rail in the side bar, or a floating bar when unfolded portrait.
|
|
7
7
|
//
|
|
8
8
|
|
|
9
9
|
import SwiftUI
|
|
@@ -16,12 +16,15 @@ private let railGap = Spacing.XXS
|
|
|
16
16
|
// selection bubble overflows the 48 rail cell by 5 above and below (Figma MT2)
|
|
17
17
|
private let railBubbleWidth: CGFloat = 44
|
|
18
18
|
private let railBubbleHeight: CGFloat = 58
|
|
19
|
-
let
|
|
19
|
+
private let floatingHeight = DuoMetrics.floatingTabBarHeight
|
|
20
20
|
private let floatingPadding = Spacing.XS
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
private let
|
|
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
|
|
25
28
|
private let tabSpring = Animation.interpolatingSpring(mass: 0.8, stiffness: 220, damping: 18)
|
|
26
29
|
|
|
27
30
|
/// Room tab screens leave for the Duo tab bar, provided by BottomTab to its tab StackScreens.
|
|
@@ -51,47 +54,34 @@ func duoRailHeight(itemCount: Int, hasQr: Bool, compressed: Bool = false) -> CGF
|
|
|
51
54
|
}
|
|
52
55
|
|
|
53
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
|
+
|
|
54
62
|
func duoTabBarInsets(itemCount: Int, hasFloatingButton: Bool, tabBarCompressed: Bool = false) -> DuoTabBarInsets {
|
|
55
63
|
guard isIPhoneDuo else { return DuoTabBarInsets() }
|
|
56
64
|
guard let sideBar else {
|
|
57
|
-
|
|
65
|
+
// overlay is measured from the bottom safe area, the bar reaches into it
|
|
66
|
+
return DuoTabBarInsets(overlay: floatingHeight + duoFloatingTabBarBottom - insets.bottom)
|
|
58
67
|
}
|
|
59
68
|
let rail = duoRailHeight(itemCount: itemCount, hasQr: hasFloatingButton, compressed: tabBarCompressed)
|
|
60
69
|
return DuoTabBarInsets(sideBarReserve: sideBar.bottom + rail + DuoBarCompression.groupGap)
|
|
61
70
|
}
|
|
62
71
|
}
|
|
63
72
|
|
|
64
|
-
///
|
|
65
|
-
struct DuoBarMaterial<S:
|
|
73
|
+
/// RN `BarMaterial`: surface with a 1pt border.
|
|
74
|
+
struct DuoBarMaterial<S: InsettableShape>: ViewModifier {
|
|
66
75
|
let shape: S
|
|
67
|
-
var visible: Bool = true
|
|
68
|
-
|
|
69
|
-
func body(content: Content) -> some View {
|
|
70
|
-
content.background(material.allowsHitTesting(false))
|
|
71
|
-
}
|
|
72
76
|
|
|
73
|
-
@
|
|
74
|
-
private var material: some View {
|
|
75
|
-
#if compiler(>=6.2)
|
|
76
|
-
if #available(iOS 26.0, *) {
|
|
77
|
-
// glass materializes by toggling its effect, it does not fade with opacity
|
|
78
|
-
Color.clear
|
|
79
|
-
.glassEffect(visible ? .regular : .identity, in: shape)
|
|
80
|
-
.shadow(color: Color.black.opacity(visible ? 0.16 : 0), radius: 16, x: 0, y: 6)
|
|
81
|
-
.animation(.easeInOut(duration: 0.25), value: visible)
|
|
82
|
-
} else {
|
|
83
|
-
blur
|
|
84
|
-
}
|
|
85
|
-
#else
|
|
86
|
-
blur
|
|
87
|
-
#endif
|
|
88
|
-
}
|
|
77
|
+
@Environment(\.appTheme) private var theme
|
|
89
78
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
.
|
|
93
|
-
|
|
94
|
-
|
|
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
|
+
)
|
|
95
85
|
}
|
|
96
86
|
}
|
|
97
87
|
|
|
@@ -126,7 +116,7 @@ private struct DuoRailTabBar: View {
|
|
|
126
116
|
let onTabSelected: (Int) -> Void
|
|
127
117
|
let compression: DuoBarCompression
|
|
128
118
|
|
|
129
|
-
@
|
|
119
|
+
@State private var pressedIndex: Int?
|
|
130
120
|
|
|
131
121
|
private var isRight: Bool { sideBar.edge == .right }
|
|
132
122
|
|
|
@@ -157,15 +147,15 @@ private struct DuoRailTabBar: View {
|
|
|
157
147
|
.ignoresSafeArea()
|
|
158
148
|
}
|
|
159
149
|
|
|
160
|
-
///
|
|
150
|
+
/// Column of icon-only tab items with the sliding selection bubble.
|
|
161
151
|
private var rail: some View {
|
|
162
152
|
ZStack(alignment: .top) {
|
|
163
|
-
|
|
164
|
-
.fill(theme.colors.background.selected)
|
|
153
|
+
DuoSelectionMaterial(lens: pressedIndex != nil)
|
|
165
154
|
.frame(width: railBubbleWidth, height: railBubbleHeight)
|
|
166
155
|
.offset(y: railPadding - (railBubbleHeight - itemSize) / 2
|
|
167
|
-
+ CGFloat(selectedIndex) * (itemSize + railGap))
|
|
156
|
+
+ CGFloat(pressedIndex ?? selectedIndex) * (itemSize + railGap))
|
|
168
157
|
.animation(tabSpring, value: selectedIndex)
|
|
158
|
+
.animation(tabSpring, value: pressedIndex)
|
|
169
159
|
|
|
170
160
|
VStack(spacing: railGap) {
|
|
171
161
|
ForEach(items.indices, id: \.self) { index in
|
|
@@ -177,19 +167,19 @@ private struct DuoRailTabBar: View {
|
|
|
177
167
|
.frame(width: itemSize, height: itemSize)
|
|
178
168
|
.contentShape(Rectangle())
|
|
179
169
|
}
|
|
180
|
-
.buttonStyle(
|
|
170
|
+
.buttonStyle(DuoLensPressStyle(index: index, pressedIndex: $pressedIndex))
|
|
181
171
|
.accessibilityLabel(items[index].label)
|
|
182
172
|
}
|
|
183
173
|
}
|
|
184
174
|
.padding(.vertical, railPadding)
|
|
185
175
|
}
|
|
186
176
|
.frame(width: itemSize)
|
|
187
|
-
.modifier(DuoBarMaterial(shape:
|
|
177
|
+
.modifier(DuoBarMaterial(shape: Capsule()))
|
|
188
178
|
.accessibilityIdentifier("bottom_tab_bar")
|
|
189
179
|
}
|
|
190
180
|
}
|
|
191
181
|
|
|
192
|
-
/// Floating labelled bar for the unfolded portrait display, QR in
|
|
182
|
+
/// Floating labelled bar for the unfolded portrait display, QR pinned in its own platter beside it.
|
|
193
183
|
private struct DuoFloatingTabBar: View {
|
|
194
184
|
let items: [BottomTabItem]
|
|
195
185
|
let floatingButton: BottomTabFloatingButton?
|
|
@@ -198,45 +188,48 @@ private struct DuoFloatingTabBar: View {
|
|
|
198
188
|
|
|
199
189
|
@Environment(\.screenLayout) private var layout
|
|
200
190
|
@Environment(\.appTheme) private var theme
|
|
191
|
+
@State private var pressedIndex: Int?
|
|
201
192
|
|
|
202
193
|
private var barWidth: CGFloat {
|
|
203
|
-
let
|
|
204
|
-
|
|
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)
|
|
205
198
|
}
|
|
206
199
|
|
|
207
|
-
private var
|
|
208
|
-
private var middle: Int { items.count / 2 }
|
|
209
|
-
private var cellCount: Int { items.count + (useQr ? 1 : 0) }
|
|
210
|
-
private var cellWidth: CGFloat { (barWidth - floatingPadding * 2) / CGFloat(max(cellCount, 1)) }
|
|
211
|
-
private var visualIndex: Int { useQr && selectedIndex >= middle ? selectedIndex + 1 : selectedIndex }
|
|
200
|
+
private var cellWidth: CGFloat { (barWidth - floatingPadding * 2) / CGFloat(max(items.count, 1)) }
|
|
212
201
|
|
|
213
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 {
|
|
214
216
|
ZStack(alignment: .leading) {
|
|
215
|
-
|
|
216
|
-
.fill(theme.colors.background.selected)
|
|
217
|
+
DuoSelectionMaterial(lens: pressedIndex != nil)
|
|
217
218
|
.frame(width: cellWidth)
|
|
218
|
-
.offset(x: CGFloat(
|
|
219
|
-
.animation(tabSpring, value:
|
|
219
|
+
.offset(x: CGFloat(pressedIndex ?? selectedIndex) * cellWidth)
|
|
220
|
+
.animation(tabSpring, value: selectedIndex)
|
|
221
|
+
.animation(tabSpring, value: pressedIndex)
|
|
220
222
|
|
|
221
223
|
HStack(spacing: 0) {
|
|
222
|
-
ForEach(
|
|
223
|
-
|
|
224
|
-
DuoQrButton(data: floatingButton, size: floatingQrSize)
|
|
225
|
-
.frame(width: cellWidth)
|
|
226
|
-
} else {
|
|
227
|
-
let index = useQr && cell > middle ? cell - 1 : cell
|
|
228
|
-
tabCell(index)
|
|
229
|
-
}
|
|
224
|
+
ForEach(items.indices, id: \.self) { index in
|
|
225
|
+
tabCell(index)
|
|
230
226
|
}
|
|
231
227
|
}
|
|
232
228
|
}
|
|
233
229
|
.padding(floatingPadding)
|
|
234
|
-
.frame(width: barWidth, height:
|
|
230
|
+
.frame(width: barWidth, height: floatingHeight)
|
|
235
231
|
.modifier(DuoBarMaterial(shape: Capsule()))
|
|
236
232
|
.accessibilityIdentifier("bottom_tab_bar")
|
|
237
|
-
.padding(.bottom, layout.insets.bottom + Spacing.M)
|
|
238
|
-
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
|
|
239
|
-
.ignoresSafeArea()
|
|
240
233
|
}
|
|
241
234
|
|
|
242
235
|
private func tabCell(_ index: Int) -> some View {
|
|
@@ -247,20 +240,52 @@ private struct DuoFloatingTabBar: View {
|
|
|
247
240
|
MomoText(
|
|
248
241
|
items[index].label,
|
|
249
242
|
typography: .labelXsMedium,
|
|
250
|
-
color: selected ? theme.colors.primary : theme.colors.text.
|
|
243
|
+
color: selected ? theme.colors.primary : theme.colors.text.hint,
|
|
251
244
|
textAlign: .center,
|
|
252
245
|
maxLines: 1
|
|
253
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))
|
|
254
249
|
}
|
|
255
250
|
.frame(width: cellWidth)
|
|
256
251
|
.frame(maxHeight: .infinity)
|
|
257
252
|
.contentShape(Rectangle())
|
|
258
253
|
}
|
|
259
|
-
.buttonStyle(
|
|
254
|
+
.buttonStyle(DuoLensPressStyle(index: index, pressedIndex: $pressedIndex))
|
|
260
255
|
.accessibilityLabel(items[index].label)
|
|
261
256
|
}
|
|
262
257
|
}
|
|
263
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
|
+
|
|
264
289
|
private struct DuoTabIcon: View {
|
|
265
290
|
let item: BottomTabItem
|
|
266
291
|
let selected: Bool
|
|
@@ -274,7 +299,7 @@ private struct DuoTabIcon: View {
|
|
|
274
299
|
}
|
|
275
300
|
|
|
276
301
|
var body: some View {
|
|
277
|
-
Icon(source: item.icon, size: iconSize, color: selected ? theme.colors.primary : theme.colors.text.
|
|
302
|
+
Icon(source: item.icon, size: iconSize, color: selected ? theme.colors.primary : theme.colors.text.hint)
|
|
278
303
|
.overlay(alignment: .topTrailing) {
|
|
279
304
|
if let badgeLabel, !badgeLabel.isEmpty {
|
|
280
305
|
Badge(label: badgeLabel).offset(x: Spacing.S, y: -Spacing.XS)
|
|
@@ -90,6 +90,27 @@ private let duoGradientExtra: CGFloat = 52
|
|
|
90
90
|
// scroll distance over which the extended gradient collapses to the header
|
|
91
91
|
private let duoExtendedCollapseDistance: CGFloat = 100
|
|
92
92
|
|
|
93
|
+
// scroll distance over which the bar fades in
|
|
94
|
+
private let duoBarFadeDistance: CGFloat = 16
|
|
95
|
+
|
|
96
|
+
/// Port of RN `DuoGlassBackground`: translucent surface with a hairline, shown once the content
|
|
97
|
+
/// scrolls `fadeStart` under the header.
|
|
98
|
+
struct DuoHeaderBar: View {
|
|
99
|
+
let scrollOffset: CGFloat
|
|
100
|
+
var fadeStart: CGFloat = 0
|
|
101
|
+
|
|
102
|
+
@Environment(\.appTheme) private var theme
|
|
103
|
+
|
|
104
|
+
var body: some View {
|
|
105
|
+
theme.colors.background.surface.opacity(0.8)
|
|
106
|
+
.overlay(alignment: .bottom) {
|
|
107
|
+
theme.colors.border.default.frame(height: 0.5)
|
|
108
|
+
}
|
|
109
|
+
.opacity(min(max((scrollOffset - fadeStart) / duoBarFadeDistance, 0), 1))
|
|
110
|
+
.allowsHitTesting(false)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
93
114
|
/// Port of RN `DuoScrollGradient`: with the glass header the content scrolls under the bar, so the
|
|
94
115
|
/// header gradient is drawn under it. Compose draws this inside `HeaderBackground`.
|
|
95
116
|
struct DuoScrollGradient: View {
|
|
@@ -114,7 +135,7 @@ struct DuoScrollGradient: View {
|
|
|
114
135
|
let progress = min(scroll / duoExtendedCollapseDistance, 1)
|
|
115
136
|
gradient
|
|
116
137
|
.frame(height: headerHeight + (extended ? duoGradientExtra * (1 - progress) : 0))
|
|
117
|
-
.opacity(1 - min(scroll / duoGradientExtra, 1))
|
|
138
|
+
.opacity(Double(1 - min(scroll / duoGradientExtra, 1)))
|
|
118
139
|
}
|
|
119
140
|
}
|
|
120
141
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
|
@@ -57,6 +57,7 @@ struct SnackBarView: View {
|
|
|
57
57
|
let bottomInset: CGFloat
|
|
58
58
|
|
|
59
59
|
@EnvironmentObject private var navigator: Navigator
|
|
60
|
+
@Environment(\.screenLayout) private var layout
|
|
60
61
|
|
|
61
62
|
@State private var contentHeight: CGFloat = 0
|
|
62
63
|
@State private var offsetY: CGFloat = 0
|
|
@@ -75,6 +76,9 @@ struct SnackBarView: View {
|
|
|
75
76
|
)
|
|
76
77
|
.offset(y: offsetY)
|
|
77
78
|
.padding(.bottom, bottomInset)
|
|
79
|
+
// iPhone Duo: clear of the side toolbar strip, like the screen content (0 elsewhere)
|
|
80
|
+
.padding(.leading, layout.insetLeft)
|
|
81
|
+
.padding(.trailing, layout.insetRight)
|
|
78
82
|
}
|
|
79
83
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
80
84
|
.ignoresSafeArea()
|
|
@@ -34,11 +34,15 @@ struct ModalScreen<Content: View>: View {
|
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
content()
|
|
37
|
+
// iPhone Duo: capped so it stays a dialog on the inner display; nil leaves the size alone
|
|
38
|
+
.frame(
|
|
39
|
+
maxWidth: layout.isDuoDevice ? layout.duoModalMaxWidth : nil,
|
|
40
|
+
maxHeight: layout.isDuoDevice ? layout.duoModalMaxHeight : nil
|
|
41
|
+
)
|
|
37
42
|
.scaleEffect(contentScale)
|
|
38
43
|
.opacity(contentOpacity)
|
|
39
|
-
// iPhone Duo: center
|
|
40
|
-
.padding(
|
|
41
|
-
.padding(.bottom, layout.isDuoDevice ? layout.insets.bottom : 0)
|
|
44
|
+
// iPhone Duo: center in the safe area, clear of the side toolbar strip and the home indicator
|
|
45
|
+
.padding(layout.isDuoDevice ? layout.insets : EdgeInsets())
|
|
42
46
|
}
|
|
43
47
|
// Full screen like RN's Modal, so asymmetric landscape insets don't shift the content sideways.
|
|
44
48
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|