@momo-kits/native-kits 0.167.1-duo.5-debug → 0.167.1-duo.6
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 +8 -3
- package/ios/Application/HeaderRight.swift +25 -12
- package/ios/Application/Layout/ScreenLayout.swift +196 -0
- package/ios/Application/Navigation/BottomTab/BottomTab.swift +30 -9
- package/ios/Application/Navigation/BottomTab/BottomTabBar.swift +5 -2
- package/ios/Application/Navigation/BottomTab/DuoTabBar.swift +275 -0
- package/ios/Application/Navigation/HeaderUser.swift +4 -1
- package/ios/Application/Navigation/NavigationContainer.swift +3 -0
- package/ios/Application/Navigation/Overplay/BottomSheet.swift +41 -4
- package/ios/Application/Navigation/Overplay/ModalScreen.swift +5 -1
- package/ios/Application/Navigation/component/DuoScrollGradient.swift +55 -0
- package/ios/Application/Navigation/component/Header.swift +9 -3
- package/ios/Application/Navigation/component/HeaderBackground.swift +3 -2
- package/ios/Application/Navigation/component/NavigationFooter.swift +1 -1
- package/ios/Application/Navigation/component/NavigationHeaderRight.swift +61 -9
- package/ios/Application/Navigation/component/SideBar.swift +155 -0
- package/ios/Application/StackScreen.swift +135 -18
- package/ios/Popup/PopupDisplay.swift +84 -68
- package/ios/Popup/PopupLayout.swift +45 -0
- package/ios/Popup/PopupNotify.swift +43 -32
- package/ios/Popup/PopupPromotion.swift +17 -5
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/DuoTabDemo.swift +76 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/Home.swift +1 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/IPhoneDuoDemo.swift +170 -0
- package/package.json +1 -1
- package/build.gradle.kts +0 -11
- package/compose/build.gradle.kts +0 -190
- package/compose/build.gradle.kts.backup +0 -190
- package/compose/compose.podspec +0 -47
- package/compose/src/androidMain/kotlin/vn/momo/kits/const/HostFont.android.kt +0 -19
- package/compose/src/androidMain/kotlin/vn/momo/kits/navigation/ScrollToTop.android.kt +0 -6
- package/compose/src/androidMain/kotlin/vn/momo/kits/platform/BackGesture.android.kt +0 -12
- package/compose/src/androidMain/kotlin/vn/momo/kits/platform/OsFontScale.android.kt +0 -7
- package/compose/src/androidMain/kotlin/vn/momo/kits/platform/Platform.android.kt +0 -151
- package/compose/src/commonMain/kotlin/vn/momo/kits/application/AnimationSearchInput.kt +0 -52
- package/compose/src/commonMain/kotlin/vn/momo/kits/application/Context.kt +0 -124
- package/compose/src/commonMain/kotlin/vn/momo/kits/application/FloatingButton.kt +0 -183
- package/compose/src/commonMain/kotlin/vn/momo/kits/application/Header.kt +0 -207
- package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderAnimated.kt +0 -39
- package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderBackground.kt +0 -86
- package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderDefault.kt +0 -76
- package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderExtended.kt +0 -76
- package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderRight.kt +0 -293
- package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderTitle.kt +0 -33
- package/compose/src/commonMain/kotlin/vn/momo/kits/application/LiteScreen.kt +0 -831
- package/compose/src/commonMain/kotlin/vn/momo/kits/application/Localize.kt +0 -269
- package/compose/src/commonMain/kotlin/vn/momo/kits/application/NavigationContainer.kt +0 -110
- package/compose/src/commonMain/kotlin/vn/momo/kits/application/Screen.kt +0 -378
- package/compose/src/commonMain/kotlin/vn/momo/kits/application/WidgetContainer.kt +0 -56
- package/compose/src/commonMain/kotlin/vn/momo/kits/application/useHeaderSearchAnimation.kt +0 -69
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Avatar.kt +0 -157
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Badge.kt +0 -82
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/BadgeDot.kt +0 -32
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/BadgeRibbon.kt +0 -338
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/BaselineView.kt +0 -175
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Button.kt +0 -356
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Carousel.kt +0 -113
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/CheckBox.kt +0 -89
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Chip.kt +0 -128
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Collapse.kt +0 -209
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/CupertinoOverscroll.kt +0 -542
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Divider.kt +0 -49
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Icon.kt +0 -76
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/IconButton.kt +0 -125
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Image.kt +0 -231
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Information.kt +0 -189
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Input.kt +0 -475
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputDropDown.kt +0 -114
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputMoney.kt +0 -266
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputOTP.kt +0 -240
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputPhoneNumber.kt +0 -223
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputSearch.kt +0 -264
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputTextArea.kt +0 -185
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/LazyColumnWithBouncing.kt +0 -343
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Loader.kt +0 -108
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/PaginationDot.kt +0 -56
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/PaginationNumber.kt +0 -41
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/PaginationScroll.kt +0 -84
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/PaginationWhiteDot.kt +0 -40
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/PopupNotify.kt +0 -334
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/PopupPromotion.kt +0 -98
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/ProgressInfo.kt +0 -310
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Radio.kt +0 -59
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Rating.kt +0 -87
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/ScaleSizeScope.kt +0 -21
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Skeleton.kt +0 -90
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Slider.kt +0 -323
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Stepper.kt +0 -220
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Steps.kt +0 -473
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/SuggestAction.kt +0 -122
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Swipe.kt +0 -199
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Switch.kt +0 -91
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/TabView.kt +0 -552
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Tag.kt +0 -88
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Text.kt +0 -125
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Title.kt +0 -207
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Tooltip.kt +0 -498
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/TrustBanner.kt +0 -176
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Uploader.kt +0 -182
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/DateTimePicker.kt +0 -193
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/DateTimePickerTypes.kt +0 -29
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/DateTimePickerUtils.kt +0 -234
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/WheelPicker.kt +0 -188
- package/compose/src/commonMain/kotlin/vn/momo/kits/const/Colors.kt +0 -325
- package/compose/src/commonMain/kotlin/vn/momo/kits/const/HostFont.kt +0 -16
- 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/compose/src/commonMain/kotlin/vn/momo/kits/const/Radius.kt +0 -12
- package/compose/src/commonMain/kotlin/vn/momo/kits/const/Spacing.kt +0 -16
- package/compose/src/commonMain/kotlin/vn/momo/kits/const/Theme.kt +0 -188
- package/compose/src/commonMain/kotlin/vn/momo/kits/const/Typography.kt +0 -297
- package/compose/src/commonMain/kotlin/vn/momo/kits/layout/Card.kt +0 -54
- package/compose/src/commonMain/kotlin/vn/momo/kits/layout/Item.kt +0 -50
- package/compose/src/commonMain/kotlin/vn/momo/kits/layout/ScreenLayout.kt +0 -157
- package/compose/src/commonMain/kotlin/vn/momo/kits/layout/Section.kt +0 -41
- package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/AutomationId.kt +0 -52
- package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/Clickable.kt +0 -66
- package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/Conditional.kt +0 -11
- package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/DeprecatedModifier.kt +0 -104
- package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/Shadow.kt +0 -48
- package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/Size.kt +0 -51
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/BottomSheet.kt +0 -299
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/HostBackGestureBridge.kt +0 -58
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/ModalScreen.kt +0 -129
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigation.kt +0 -106
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/NavigationContainer.kt +0 -205
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigator.kt +0 -393
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/ScrollToTop.kt +0 -8
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/StackScreen.kt +0 -622
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/StackTransition.kt +0 -148
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/SwipeBackHaptic.kt +0 -35
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTab.kt +0 -175
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTabBar.kt +0 -232
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/CurvedContainer.kt +0 -86
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/FloatingButton.kt +0 -169
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/Header.kt +0 -381
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderBackground.kt +0 -83
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderJourney.kt +0 -105
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderLocation.kt +0 -74
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderRight.kt +0 -347
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderTitle.kt +0 -37
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderUser.kt +0 -363
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/SideBar.kt +0 -169
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/SnackBar.kt +0 -132
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/tracking/ScreenTracker.kt +0 -127
- package/compose/src/commonMain/kotlin/vn/momo/kits/platform/BackGesture.kt +0 -12
- package/compose/src/commonMain/kotlin/vn/momo/kits/platform/ComposeLottieAnimation.kt +0 -91
- package/compose/src/commonMain/kotlin/vn/momo/kits/platform/OsFontScale.kt +0 -9
- package/compose/src/commonMain/kotlin/vn/momo/kits/platform/Platform.kt +0 -71
- package/compose/src/commonMain/kotlin/vn/momo/kits/utils/Icons.kt +0 -1329
- package/compose/src/commonMain/kotlin/vn/momo/kits/utils/Resources.kt +0 -55
- package/compose/src/commonMain/kotlin/vn/momo/kits/utils/Tracking.kt +0 -15
- package/compose/src/commonMain/kotlin/vn/momo/kits/utils/Utils.kt +0 -118
- package/compose/src/iosMain/kotlin/vn/momo/kits/const/HostFont.ios.kt +0 -40
- package/compose/src/iosMain/kotlin/vn/momo/kits/navigation/HostBackGesture.ios.kt +0 -45
- package/compose/src/iosMain/kotlin/vn/momo/kits/navigation/ScrollToTop.ios.kt +0 -32
- package/compose/src/iosMain/kotlin/vn/momo/kits/platform/BackGesture.ios.kt +0 -38
- package/compose/src/iosMain/kotlin/vn/momo/kits/platform/OsFontScale.ios.kt +0 -66
- package/compose/src/iosMain/kotlin/vn/momo/kits/platform/Platform.ios.kt +0 -213
- package/gradle/libs.versions.toml +0 -69
- package/gradle/wrapper/gradle-wrapper.jar +0 -0
- package/gradle/wrapper/gradle-wrapper.properties +0 -8
- package/gradle.properties +0 -26
- package/gradlew +0 -252
- package/gradlew.bat +0 -94
- package/ios-demo/README.md +0 -120
- package/settings.gradle.kts +0 -64
package/CLAUDE.md
CHANGED
|
@@ -61,11 +61,16 @@ Publishing is automated (`publish.sh` + GitLab CI on `[ci build]` / `main`). Do
|
|
|
61
61
|
- Icons are compiled in: `ios/Icon/Icons.swift` is generated from Compose `utils/Icons.kt` by `node scripts/gen-ios-icons.mjs` — don't hand-edit it. A host `icon.json` in the main bundle only adds keys the kit lacks.
|
|
62
62
|
- Bundled assets (colors, Lottie JSON) live in `ios/Resources` and ship in the `MoMoUIKitsResources` bundle; load them through `MoMoUIKitsResources.bundle`, never `Bundle.main`.
|
|
63
63
|
|
|
64
|
-
## Screen layout / iPhone Duo (port of RN `useScreenLayout
|
|
64
|
+
## Screen layout / iPhone Duo (port of RN `useScreenLayout`, momo-kits !664)
|
|
65
65
|
|
|
66
|
-
- `layout/ScreenLayout.kt`: `getScreenLayout()` returns window size, side
|
|
66
|
+
- `layout/ScreenLayout.kt`: `getScreenLayout()` returns window size, raw top/bottom + side insets, `contentWidth`, header geometry, the iPhone Duo `sideBar` and `duoTopInset`. `NavigationContainer` computes it once and provides `LocalScreenLayout`; reactive to fold/unfold and rotation (window size + `WindowInsets`). Metrics (`DuoMetrics`, 72pt iPhone header floor, 20pt status-bar threshold) mirror RN `Layout/utils.ts` — change both together.
|
|
67
|
+
- iPhone-only: everything gates on `isPhoneIdiom()` (`platform/`), never on "iOS", so iPad windows keep the regular layout. Two detectors, as in RN: `isIPhoneDuo` (window aspect < 1.6 or asymmetric side insets) drives layout; `isIPhoneDuoDevice` (short side of screen or window > 440, RN `device.ts`) drives popups, modal and bottom sheet.
|
|
67
68
|
- Header height is `headerHeight` (not `AppStatusBar + HEADER_HEIGHT`); the bar row is `headerBarHeight`. When the status bar is off the top edge (iPhone landscape, iPhone Duo) `headerStatusBarHeight` is 0 so the title stays centered.
|
|
68
|
-
- iPhone Duo with a side strip ≥ 60pt: back + toolkit leave the bar for `HeaderSideBar` (`navigation/component/SideBar.kt`, 48pt glass controls); custom header-right stays in the bar. The column starts
|
|
69
|
+
- iPhone Duo with a side strip ≥ 60pt: back + toolkit leave the bar for `HeaderSideBar` (`navigation/component/SideBar.kt`, 48pt glass controls); custom header-right stays in the bar. The column starts at 120pt when the inner display is landscape (height ≥ 600), else 170pt, or below the header when the status bar is on top; UIKit's `statusBarFrame` is useless on Duo. Unlike RN it is clamped 24pt above footer / tab bar / rail.
|
|
70
|
+
- Tab bar on Duo is `bottomtab/DuoTabBar.kt`: a rail in the side strip (anchored 24pt from the bottom, QR below it) or, without a side bar, a floating bar spanning 6/8 grid columns. Both overlay the content; `duoFloatingTabBarInset` pads tab content, footer and FAB.
|
|
71
|
+
- Glass header: on Duo with a side bar, Default/Extended headers without search and with a `ScrollState` let content scroll under the bar (`usesDuoGlassHeader`); the bar fades in a translucent surface + hairline over 16pt (Compose has no backdrop blur — RN's fallback look), the gradient scrolls away / collapses.
|
|
72
|
+
- Overlays on Duo: popups cap at 440pt wide and the room between `duoTopInset` and the bottom inset (body scrolls, actions stay); modal centers inside that room; bottom sheet uses the window height, centers at width ≥ 600 and pads by side insets in landscape.
|
|
73
|
+
- Font scale deliberately uses the short side (`min(w, h)`), unlike RN which reads the window width — landscape must not inflate every font.
|
|
69
74
|
- `StackScreen` pads content/footer/search/FAB by the side insets and provides an inset-consumed layout to its content; a bottom-tab root stays full width (tab bar + each tab's StackScreen handle insets). Grid columns use `contentWidth`.
|
|
70
75
|
|
|
71
76
|
## i18n / Localize (port of RN `Localize`)
|
|
@@ -7,20 +7,31 @@ public struct HeaderRightData {
|
|
|
7
7
|
var tools: [ToolGroup]
|
|
8
8
|
var toolCallback: ((String) -> Void)?
|
|
9
9
|
var useSystemTools: Bool
|
|
10
|
+
var useCloseIcon: Bool
|
|
10
11
|
|
|
11
12
|
public init(
|
|
12
13
|
useShortcut: Bool = false,
|
|
13
14
|
useMore: Bool = false,
|
|
14
15
|
tools: [ToolGroup] = [],
|
|
15
16
|
toolCallback: ((String) -> Void)? = nil,
|
|
16
|
-
useSystemTools: Bool = true
|
|
17
|
+
useSystemTools: Bool = true,
|
|
18
|
+
useCloseIcon: Bool = false
|
|
17
19
|
) {
|
|
18
20
|
self.useShortcut = useShortcut
|
|
19
21
|
self.useMore = useMore
|
|
20
22
|
self.tools = tools
|
|
21
23
|
self.toolCallback = toolCallback
|
|
22
24
|
self.useSystemTools = useSystemTools
|
|
25
|
+
self.useCloseIcon = useCloseIcon
|
|
23
26
|
}
|
|
27
|
+
|
|
28
|
+
/// Close-circle instead of home, as RN `headerRight.useCloseIcon`.
|
|
29
|
+
var closeIcon: String { useCloseIcon ? "16_navigation_close_circle" : "16_basic_home" }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/// The Help Center mini-app hides its own help-center button.
|
|
33
|
+
func showsHelpCenter(_ context: MiniAppContext?) -> Bool {
|
|
34
|
+
context?.appId != "vn.momo.helpcenter"
|
|
24
35
|
}
|
|
25
36
|
|
|
26
37
|
public struct Tool {
|
|
@@ -243,17 +254,19 @@ struct ToolkitHeaderRight: View {
|
|
|
243
254
|
}
|
|
244
255
|
|
|
245
256
|
HStack(alignment: .center, spacing: 0) {
|
|
246
|
-
|
|
247
|
-
.
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
+
if showsHelpCenter(environment.applicationContext) {
|
|
258
|
+
Icon(source: "help_center", size: 20, color: tintColor ?? Colors.black17)
|
|
259
|
+
.padding(4)
|
|
260
|
+
.onTapGesture {
|
|
261
|
+
onPressHelpCenter()
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
Rectangle()
|
|
265
|
+
.fill(tintColor ?? Colors.black20)
|
|
266
|
+
.frame(width: 0.5, height: 12)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
Icon(source: headerRight?.closeIcon ?? "16_basic_home", size: 20, color: tintColor ?? Colors.black17)
|
|
257
270
|
.padding(4)
|
|
258
271
|
.onTapGesture {
|
|
259
272
|
onPressClose()
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
//
|
|
2
|
+
// ScreenLayout.swift
|
|
3
|
+
// MoMoUIKits
|
|
4
|
+
//
|
|
5
|
+
// Port of momo-kits RN `useScreenLayout` (Layout/utils.ts) and `Application/device.ts`:
|
|
6
|
+
// window size, safe-area insets, content width, header geometry and the iPhone Duo side bar.
|
|
7
|
+
//
|
|
8
|
+
|
|
9
|
+
import SwiftUI
|
|
10
|
+
import UIKit
|
|
11
|
+
|
|
12
|
+
public enum SideBarEdge: Equatable {
|
|
13
|
+
case left, right
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
public struct SideBarLayout: Equatable {
|
|
17
|
+
public let edge: SideBarEdge
|
|
18
|
+
public let width: CGFloat
|
|
19
|
+
/// Preferred window y of the first control; StackScreen still keeps the column above the bottom bars.
|
|
20
|
+
public let top: CGFloat
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
public struct ScreenLayout: Equatable {
|
|
24
|
+
public let width: CGFloat
|
|
25
|
+
public let height: CGFloat
|
|
26
|
+
public let isIPhoneDuo: Bool
|
|
27
|
+
public let insets: EdgeInsets
|
|
28
|
+
public let insetLeft: CGFloat
|
|
29
|
+
public let insetRight: CGFloat
|
|
30
|
+
public let contentWidth: CGFloat
|
|
31
|
+
public let headerHeight: CGFloat
|
|
32
|
+
/// 0 when the status bar is off the top edge, so the bar row fills the header and keeps the title centered.
|
|
33
|
+
public let headerStatusBarHeight: CGFloat
|
|
34
|
+
/// iPhone Duo moves back and toolkit into a column in the side safe area; nil while they stay in the bar.
|
|
35
|
+
public let sideBar: SideBarLayout?
|
|
36
|
+
|
|
37
|
+
public var headerBarHeight: CGFloat { headerHeight - headerStatusBarHeight }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Measured by momo-kits RN (Layout/utils.ts) on the iOS 27.1 iPhone Duo simulator; keep both in sync.
|
|
41
|
+
enum DuoMetrics {
|
|
42
|
+
// Inner display is ~1.42, outer ~1.45; every slab iPhone is 1.77 or taller.
|
|
43
|
+
static let maxAspectRatio: CGFloat = 1.6
|
|
44
|
+
// Outer display is 466pt tall in landscape, inner 669pt.
|
|
45
|
+
static let innerMinHeight: CGFloat = 600
|
|
46
|
+
static let controlSize: CGFloat = 48
|
|
47
|
+
static let itemPitch: CGFloat = 50
|
|
48
|
+
static let firstItemTop: CGFloat = 170
|
|
49
|
+
static let firstItemTopLandscape: CGFloat = 120
|
|
50
|
+
// Widest short side of any single-display iPhone.
|
|
51
|
+
static let iPhoneMaxShortSide: CGFloat = 440
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
enum HeaderMetrics {
|
|
55
|
+
static let barHeight: CGFloat = HEADER_HEIGHT
|
|
56
|
+
static let iPhoneMinHeight: CGFloat = 72
|
|
57
|
+
// A smaller top inset means the status bar is not on the top edge.
|
|
58
|
+
static let statusBarMinInset: CGFloat = 20
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
private var isPhoneIdiom: Bool { UIDevice.current.userInterfaceIdiom == .phone }
|
|
62
|
+
|
|
63
|
+
private var keyWindow: UIWindow? {
|
|
64
|
+
UIApplication.shared.connectedScenes
|
|
65
|
+
.compactMap { ($0 as? UIWindowScene)?.keyWindow }
|
|
66
|
+
.first
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
extension ScreenLayout {
|
|
70
|
+
/// Snapshot of the key window; not reactive, so views read `\.screenLayout` instead.
|
|
71
|
+
public static var current: ScreenLayout {
|
|
72
|
+
guard let window = keyWindow else {
|
|
73
|
+
return make(size: UIScreen.main.bounds.size, insets: EdgeInsets())
|
|
74
|
+
}
|
|
75
|
+
let inset = window.safeAreaInsets
|
|
76
|
+
return make(
|
|
77
|
+
size: window.bounds.size,
|
|
78
|
+
insets: EdgeInsets(top: inset.top, leading: inset.left, bottom: inset.bottom, trailing: inset.right)
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
static func make(size: CGSize, insets: EdgeInsets, isPhone: Bool = isPhoneIdiom) -> ScreenLayout {
|
|
83
|
+
let width = size.width
|
|
84
|
+
let height = size.height
|
|
85
|
+
let (headerHeight, headerStatusBarHeight) = headerGeometry(topInset: insets.top, isPhone: isPhone)
|
|
86
|
+
let isDuo = isPhone && isIPhoneDuoWindow(width: width, height: height, insets: insets)
|
|
87
|
+
guard isDuo else {
|
|
88
|
+
return ScreenLayout(
|
|
89
|
+
width: width, height: height, isIPhoneDuo: false, insets: insets,
|
|
90
|
+
insetLeft: 0, insetRight: 0, contentWidth: width,
|
|
91
|
+
headerHeight: headerHeight, headerStatusBarHeight: headerStatusBarHeight, sideBar: nil
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
var sideBar: SideBarLayout?
|
|
96
|
+
let sideInset = max(insets.leading, insets.trailing)
|
|
97
|
+
// narrower strips cannot host a control 12pt from the inner edge
|
|
98
|
+
if sideInset >= DuoMetrics.controlSize + Spacing.M {
|
|
99
|
+
var top = insets.top + HeaderMetrics.barHeight + Spacing.M
|
|
100
|
+
if insets.top < HeaderMetrics.statusBarMinInset {
|
|
101
|
+
// status bar lives in the strip: start under its cluster, as the native bar does
|
|
102
|
+
let isInnerLandscape = width > height && height >= DuoMetrics.innerMinHeight
|
|
103
|
+
top = isInnerLandscape ? DuoMetrics.firstItemTopLandscape : DuoMetrics.firstItemTop
|
|
104
|
+
}
|
|
105
|
+
sideBar = SideBarLayout(
|
|
106
|
+
edge: insets.trailing >= insets.leading ? .right : .left,
|
|
107
|
+
width: sideInset,
|
|
108
|
+
top: top
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return ScreenLayout(
|
|
113
|
+
width: width, height: height, isIPhoneDuo: true, insets: insets,
|
|
114
|
+
insetLeft: insets.leading, insetRight: insets.trailing,
|
|
115
|
+
contentWidth: width - insets.leading - insets.trailing,
|
|
116
|
+
headerHeight: headerHeight, headerStatusBarHeight: headerStatusBarHeight, sideBar: sideBar
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private static func headerGeometry(topInset: CGFloat, isPhone: Bool) -> (CGFloat, CGFloat) {
|
|
121
|
+
let height = topInset + HeaderMetrics.barHeight
|
|
122
|
+
guard isPhone else { return (height, topInset) }
|
|
123
|
+
return (
|
|
124
|
+
max(height, HeaderMetrics.iPhoneMinHeight),
|
|
125
|
+
topInset < HeaderMetrics.statusBarMinInset ? 0 : topInset
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Slab iPhones report equal side insets in landscape; iPhone Duo moves the status bar to one side.
|
|
130
|
+
private static func isIPhoneDuoWindow(width: CGFloat, height: CGFloat, insets: EdgeInsets) -> Bool {
|
|
131
|
+
let shortSide = min(width, height)
|
|
132
|
+
let isSquare = shortSide > 0 && max(width, height) / shortSide < DuoMetrics.maxAspectRatio
|
|
133
|
+
return isSquare || insets.leading != insets.trailing
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/// Below the side status bar: in portrait it is about twice as tall as its inset is wide.
|
|
137
|
+
public var duoTopInset: CGFloat {
|
|
138
|
+
max(insets.top, height > width ? max(insets.leading, insets.trailing) * 2 : 0)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/// Port of RN `device.ts` `isIPhoneDuo()`: UIScreen stays on the outer display, so this
|
|
143
|
+
/// still matches when Split View narrows the window. Used by popups and overlays.
|
|
144
|
+
public func isIPhoneDuoDevice(windowSize: CGSize? = nil) -> Bool {
|
|
145
|
+
guard isPhoneIdiom else { return false }
|
|
146
|
+
let screen = UIScreen.main.bounds.size
|
|
147
|
+
let window = windowSize ?? keyWindow?.bounds.size ?? screen
|
|
148
|
+
return max(min(screen.width, screen.height), min(window.width, window.height)) > DuoMetrics.iPhoneMaxShortSide
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// MARK: - Environment
|
|
152
|
+
|
|
153
|
+
private struct ScreenLayoutKey: EnvironmentKey {
|
|
154
|
+
// Computed so an un-injected subtree (bare UIHostingController) still reads the live window.
|
|
155
|
+
static var defaultValue: ScreenLayout { .current }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
public extension EnvironmentValues {
|
|
159
|
+
var screenLayout: ScreenLayout {
|
|
160
|
+
get { self[ScreenLayoutKey.self] }
|
|
161
|
+
set { self[ScreenLayoutKey.self] = newValue }
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/// Re-reads the window whenever this view's size changes (rotation, fold/unfold, Split View).
|
|
166
|
+
private struct ScreenLayoutReader: ViewModifier {
|
|
167
|
+
@State private var layout: ScreenLayout = .current
|
|
168
|
+
|
|
169
|
+
func body(content: Content) -> some View {
|
|
170
|
+
content
|
|
171
|
+
.environment(\.screenLayout, layout)
|
|
172
|
+
.background(
|
|
173
|
+
GeometryReader { proxy in
|
|
174
|
+
Color.clear
|
|
175
|
+
.onAppear { refresh() }
|
|
176
|
+
.onChange(of: proxy.size) { _ in refresh() }
|
|
177
|
+
.onChange(of: proxy.safeAreaInsets) { _ in refresh() }
|
|
178
|
+
}
|
|
179
|
+
)
|
|
180
|
+
.onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in
|
|
181
|
+
refresh()
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private func refresh() {
|
|
186
|
+
let next = ScreenLayout.current
|
|
187
|
+
if next != layout { layout = next }
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
public extension View {
|
|
192
|
+
/// Provides `\.screenLayout`; NavigationContainer applies it, a bare UIHostingController root should too.
|
|
193
|
+
func momoScreenLayout() -> some View {
|
|
194
|
+
modifier(ScreenLayoutReader())
|
|
195
|
+
}
|
|
196
|
+
}
|
|
@@ -16,6 +16,7 @@ public struct BottomTab: View {
|
|
|
16
16
|
private let floatingButton: BottomTabFloatingButton?
|
|
17
17
|
|
|
18
18
|
@EnvironmentObject private var navigator: Navigator
|
|
19
|
+
@Environment(\.screenLayout) private var layout
|
|
19
20
|
@State private var selectedIndex: Int = 0
|
|
20
21
|
|
|
21
22
|
public init(items: [BottomTabItem], floatingButton: BottomTabFloatingButton? = nil) {
|
|
@@ -24,7 +25,14 @@ public struct BottomTab: View {
|
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
private var bottomInset: CGFloat { safeAreaBottomInset() }
|
|
27
|
-
|
|
28
|
+
// the Duo bars overlay the content or sit in the side strip, so only the home indicator is kept clear
|
|
29
|
+
private var reservedBottom: CGFloat {
|
|
30
|
+
layout.isIPhoneDuo ? layout.insets.bottom : BOTTOM_TAB_BAR_HEIGHT + bottomInset + Spacing.S
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
private var duoInsets: DuoTabBarInsets {
|
|
34
|
+
layout.duoTabBarInsets(itemCount: items.count, hasFloatingButton: floatingButton != nil)
|
|
35
|
+
}
|
|
28
36
|
|
|
29
37
|
public var body: some View {
|
|
30
38
|
ZStack(alignment: .bottom) {
|
|
@@ -38,20 +46,29 @@ public struct BottomTab: View {
|
|
|
38
46
|
}
|
|
39
47
|
}
|
|
40
48
|
.padding(.bottom, reservedBottom)
|
|
49
|
+
.environment(\.duoTabBarInsets, duoInsets)
|
|
41
50
|
.animation(.easeInOut(duration: 0.2), value: selectedIndex)
|
|
42
51
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
BottomTabBar(
|
|
52
|
+
if layout.isIPhoneDuo {
|
|
53
|
+
DuoTabBar(
|
|
46
54
|
items: items,
|
|
47
55
|
floatingButton: floatingButton,
|
|
48
56
|
selectedIndex: selectedIndex,
|
|
49
|
-
onTabSelected:
|
|
50
|
-
if index != selectedIndex { selectedIndex = index }
|
|
51
|
-
}
|
|
57
|
+
onTabSelected: selectTab
|
|
52
58
|
)
|
|
53
|
-
|
|
54
|
-
|
|
59
|
+
} else {
|
|
60
|
+
// Tab bar + home-indicator filler.
|
|
61
|
+
VStack(spacing: 0) {
|
|
62
|
+
BottomTabBar(
|
|
63
|
+
items: items,
|
|
64
|
+
floatingButton: floatingButton,
|
|
65
|
+
selectedIndex: selectedIndex,
|
|
66
|
+
onTabSelected: selectTab
|
|
67
|
+
)
|
|
68
|
+
Colors.black01
|
|
69
|
+
.frame(height: bottomInset + Spacing.S)
|
|
70
|
+
.ignoresSafeArea(edges: .horizontal)
|
|
71
|
+
}
|
|
55
72
|
}
|
|
56
73
|
}
|
|
57
74
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
@@ -59,6 +76,10 @@ public struct BottomTab: View {
|
|
|
59
76
|
.preference(key: IsBottomTabRootKey.self, value: true)
|
|
60
77
|
}
|
|
61
78
|
|
|
79
|
+
private func selectTab(_ index: Int) {
|
|
80
|
+
if index != selectedIndex { selectedIndex = index }
|
|
81
|
+
}
|
|
82
|
+
|
|
62
83
|
@ViewBuilder
|
|
63
84
|
private func tabScreen(for index: Int) -> some View {
|
|
64
85
|
let item = items[index]
|
|
@@ -65,7 +65,10 @@ struct BottomTabBar: View {
|
|
|
65
65
|
let selectedIndex: Int
|
|
66
66
|
let onTabSelected: (Int) -> Void
|
|
67
67
|
|
|
68
|
-
private var
|
|
68
|
+
@Environment(\.screenLayout) private var layout
|
|
69
|
+
|
|
70
|
+
// the bar sits between the side safe areas
|
|
71
|
+
private var screenWidth: CGFloat { layout.width - layout.insets.leading - layout.insets.trailing }
|
|
69
72
|
private var mid: Int { items.count / 2 }
|
|
70
73
|
|
|
71
74
|
private var tabWidth: CGFloat {
|
|
@@ -95,7 +98,7 @@ struct BottomTabBar: View {
|
|
|
95
98
|
}
|
|
96
99
|
.frame(maxWidth: .infinity)
|
|
97
100
|
.frame(height: BOTTOM_TAB_BAR_HEIGHT)
|
|
98
|
-
.background(Colors.black01)
|
|
101
|
+
.background(Colors.black01.ignoresSafeArea(edges: .horizontal))
|
|
99
102
|
|
|
100
103
|
// Animated selection indicator.
|
|
101
104
|
RoundedRectangle(cornerRadius: 2)
|
|
@@ -0,0 +1,275 @@
|
|
|
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 glass rail in the side bar, or a floating glass 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
|
+
let duoFloatingTabBarHeight: CGFloat = 62
|
|
20
|
+
private let floatingPadding = Spacing.XS
|
|
21
|
+
private let floatingQrSize: CGFloat = 44
|
|
22
|
+
// floating bar spans 6 of the 8 portrait grid columns, like the content
|
|
23
|
+
private let gridColumns: CGFloat = 8
|
|
24
|
+
private let gridSpan: CGFloat = 6
|
|
25
|
+
private let tabSpring = Animation.interpolatingSpring(mass: 0.8, stiffness: 220, damping: 18)
|
|
26
|
+
|
|
27
|
+
/// Room tab screens leave for the Duo tab bar, provided by BottomTab to its tab StackScreens.
|
|
28
|
+
struct DuoTabBarInsets: Equatable {
|
|
29
|
+
/// Floating bar overlays the content: scroll content, footer and FAB keep clear of it.
|
|
30
|
+
var overlay: CGFloat = 0
|
|
31
|
+
/// Rail sits at the bottom of the side strip: the header side bar stays above it.
|
|
32
|
+
var sideBarReserve: CGFloat = 0
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
private struct DuoTabBarInsetsKey: EnvironmentKey {
|
|
36
|
+
static let defaultValue = DuoTabBarInsets()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
extension EnvironmentValues {
|
|
40
|
+
var duoTabBarInsets: DuoTabBarInsets {
|
|
41
|
+
get { self[DuoTabBarInsetsKey.self] }
|
|
42
|
+
set { self[DuoTabBarInsetsKey.self] = newValue }
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
extension ScreenLayout {
|
|
47
|
+
func duoTabBarInsets(itemCount: Int, hasFloatingButton: Bool) -> DuoTabBarInsets {
|
|
48
|
+
guard isIPhoneDuo else { return DuoTabBarInsets() }
|
|
49
|
+
guard sideBar != nil else {
|
|
50
|
+
return DuoTabBarInsets(overlay: duoFloatingTabBarHeight + Spacing.M)
|
|
51
|
+
}
|
|
52
|
+
let count = CGFloat(itemCount)
|
|
53
|
+
let rail = railPadding * 2 + count * itemSize + max(count - 1, 0) * railGap
|
|
54
|
+
let qr = hasFloatingButton ? Spacing.M + itemSize : 0
|
|
55
|
+
return DuoTabBarInsets(sideBarReserve: rail + qr + Spacing.XL)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/// Glass on iOS 26, a system blur before it (RN GlassView's fallback).
|
|
60
|
+
struct DuoBarMaterial<S: Shape>: ViewModifier {
|
|
61
|
+
let shape: S
|
|
62
|
+
var visible: Bool = true
|
|
63
|
+
|
|
64
|
+
func body(content: Content) -> some View {
|
|
65
|
+
content.background(material.allowsHitTesting(false))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
@ViewBuilder
|
|
69
|
+
private var material: some View {
|
|
70
|
+
#if compiler(>=6.2)
|
|
71
|
+
if #available(iOS 26.0, *) {
|
|
72
|
+
// glass materializes by toggling its effect, it does not fade with opacity
|
|
73
|
+
Color.clear
|
|
74
|
+
.glassEffect(visible ? .regular : .identity, in: shape)
|
|
75
|
+
.shadow(color: Color.black.opacity(visible ? 0.16 : 0), radius: 16, x: 0, y: 6)
|
|
76
|
+
.animation(.easeInOut(duration: 0.25), value: visible)
|
|
77
|
+
} else {
|
|
78
|
+
blur
|
|
79
|
+
}
|
|
80
|
+
#else
|
|
81
|
+
blur
|
|
82
|
+
#endif
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private var blur: some View {
|
|
86
|
+
shape.fill(.regularMaterial)
|
|
87
|
+
.shadow(color: Color.black.opacity(0.16), radius: 16, x: 0, y: 6)
|
|
88
|
+
.opacity(visible ? 1 : 0)
|
|
89
|
+
.animation(.easeInOut(duration: 0.25), value: visible)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
struct DuoTabBar: View {
|
|
94
|
+
let items: [BottomTabItem]
|
|
95
|
+
let floatingButton: BottomTabFloatingButton?
|
|
96
|
+
let selectedIndex: Int
|
|
97
|
+
let onTabSelected: (Int) -> Void
|
|
98
|
+
|
|
99
|
+
@Environment(\.screenLayout) private var layout
|
|
100
|
+
|
|
101
|
+
var body: some View {
|
|
102
|
+
if let sideBar = layout.sideBar {
|
|
103
|
+
DuoRailTabBar(sideBar: sideBar, items: items, floatingButton: floatingButton,
|
|
104
|
+
selectedIndex: selectedIndex, onTabSelected: onTabSelected)
|
|
105
|
+
} else {
|
|
106
|
+
DuoFloatingTabBar(items: items, floatingButton: floatingButton,
|
|
107
|
+
selectedIndex: selectedIndex, onTabSelected: onTabSelected)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/// Vertical bar anchored to the bottom of the side bar, QR button below it (Figma MT1-C/E).
|
|
113
|
+
private struct DuoRailTabBar: View {
|
|
114
|
+
let sideBar: SideBarLayout
|
|
115
|
+
let items: [BottomTabItem]
|
|
116
|
+
let floatingButton: BottomTabFloatingButton?
|
|
117
|
+
let selectedIndex: Int
|
|
118
|
+
let onTabSelected: (Int) -> Void
|
|
119
|
+
|
|
120
|
+
@Environment(\.appTheme) private var theme
|
|
121
|
+
|
|
122
|
+
private var isRight: Bool { sideBar.edge == .right }
|
|
123
|
+
|
|
124
|
+
var body: some View {
|
|
125
|
+
VStack(spacing: Spacing.M) {
|
|
126
|
+
ZStack(alignment: .top) {
|
|
127
|
+
Capsule()
|
|
128
|
+
.fill(theme.colors.background.selected)
|
|
129
|
+
.frame(width: railBubbleWidth, height: railBubbleHeight)
|
|
130
|
+
.offset(y: railPadding - (railBubbleHeight - itemSize) / 2
|
|
131
|
+
+ CGFloat(selectedIndex) * (itemSize + railGap))
|
|
132
|
+
.animation(tabSpring, value: selectedIndex)
|
|
133
|
+
|
|
134
|
+
VStack(spacing: railGap) {
|
|
135
|
+
ForEach(items.indices, id: \.self) { index in
|
|
136
|
+
SwiftUI.Button { onTabSelected(index) } label: {
|
|
137
|
+
DuoTabIcon(item: items[index], selected: index == selectedIndex)
|
|
138
|
+
.frame(width: itemSize, height: itemSize)
|
|
139
|
+
.contentShape(Rectangle())
|
|
140
|
+
}
|
|
141
|
+
.buttonStyle(.plain)
|
|
142
|
+
.accessibilityLabel(items[index].label)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
.padding(.vertical, railPadding)
|
|
146
|
+
}
|
|
147
|
+
.frame(width: itemSize)
|
|
148
|
+
.modifier(DuoBarMaterial(shape: RoundedRectangle(cornerRadius: Radius.XL)))
|
|
149
|
+
.accessibilityIdentifier("bottom_tab_bar")
|
|
150
|
+
|
|
151
|
+
if let floatingButton {
|
|
152
|
+
DuoQrButton(data: floatingButton, size: itemSize)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
.padding(isRight ? .leading : .trailing, Spacing.M)
|
|
156
|
+
.frame(width: sideBar.width, alignment: isRight ? .leading : .trailing)
|
|
157
|
+
.padding(.bottom, Spacing.XL)
|
|
158
|
+
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: isRight ? .bottomTrailing : .bottomLeading)
|
|
159
|
+
.ignoresSafeArea()
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/// Floating labelled bar for the unfolded portrait display, QR in the middle cell (Figma MT1-M).
|
|
164
|
+
private struct DuoFloatingTabBar: View {
|
|
165
|
+
let items: [BottomTabItem]
|
|
166
|
+
let floatingButton: BottomTabFloatingButton?
|
|
167
|
+
let selectedIndex: Int
|
|
168
|
+
let onTabSelected: (Int) -> Void
|
|
169
|
+
|
|
170
|
+
@Environment(\.screenLayout) private var layout
|
|
171
|
+
@Environment(\.appTheme) private var theme
|
|
172
|
+
|
|
173
|
+
private var barWidth: CGFloat {
|
|
174
|
+
let columnWidth = (layout.width - 2 * Spacing.M - (gridColumns - 1) * Spacing.M) / gridColumns
|
|
175
|
+
return columnWidth * gridSpan + (gridSpan - 1) * Spacing.M
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private var useQr: Bool { floatingButton != nil && items.count % 2 == 0 }
|
|
179
|
+
private var middle: Int { items.count / 2 }
|
|
180
|
+
private var cellCount: Int { items.count + (useQr ? 1 : 0) }
|
|
181
|
+
private var cellWidth: CGFloat { (barWidth - floatingPadding * 2) / CGFloat(max(cellCount, 1)) }
|
|
182
|
+
private var visualIndex: Int { useQr && selectedIndex >= middle ? selectedIndex + 1 : selectedIndex }
|
|
183
|
+
|
|
184
|
+
var body: some View {
|
|
185
|
+
ZStack(alignment: .leading) {
|
|
186
|
+
Capsule()
|
|
187
|
+
.fill(theme.colors.background.selected)
|
|
188
|
+
.frame(width: cellWidth)
|
|
189
|
+
.offset(x: CGFloat(visualIndex) * cellWidth)
|
|
190
|
+
.animation(tabSpring, value: visualIndex)
|
|
191
|
+
|
|
192
|
+
HStack(spacing: 0) {
|
|
193
|
+
ForEach(0..<cellCount, id: \.self) { cell in
|
|
194
|
+
if useQr && cell == middle, let floatingButton {
|
|
195
|
+
DuoQrButton(data: floatingButton, size: floatingQrSize)
|
|
196
|
+
.frame(width: cellWidth)
|
|
197
|
+
} else {
|
|
198
|
+
let index = useQr && cell > middle ? cell - 1 : cell
|
|
199
|
+
tabCell(index)
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
.padding(floatingPadding)
|
|
205
|
+
.frame(width: barWidth, height: duoFloatingTabBarHeight)
|
|
206
|
+
.modifier(DuoBarMaterial(shape: Capsule()))
|
|
207
|
+
.accessibilityIdentifier("bottom_tab_bar")
|
|
208
|
+
.padding(.bottom, layout.insets.bottom + Spacing.M)
|
|
209
|
+
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
|
|
210
|
+
.ignoresSafeArea()
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
private func tabCell(_ index: Int) -> some View {
|
|
214
|
+
let selected = index == selectedIndex
|
|
215
|
+
return SwiftUI.Button { onTabSelected(index) } label: {
|
|
216
|
+
VStack(spacing: Spacing.XXS) {
|
|
217
|
+
DuoTabIcon(item: items[index], selected: selected)
|
|
218
|
+
MomoText(
|
|
219
|
+
items[index].label,
|
|
220
|
+
typography: .labelXsMedium,
|
|
221
|
+
color: selected ? theme.colors.primary : theme.colors.text.default,
|
|
222
|
+
textAlign: .center,
|
|
223
|
+
maxLines: 1
|
|
224
|
+
)
|
|
225
|
+
}
|
|
226
|
+
.frame(width: cellWidth)
|
|
227
|
+
.frame(maxHeight: .infinity)
|
|
228
|
+
.contentShape(Rectangle())
|
|
229
|
+
}
|
|
230
|
+
.buttonStyle(.plain)
|
|
231
|
+
.accessibilityLabel(items[index].label)
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
private struct DuoTabIcon: View {
|
|
236
|
+
let item: BottomTabItem
|
|
237
|
+
let selected: Bool
|
|
238
|
+
|
|
239
|
+
@Environment(\.appTheme) private var theme
|
|
240
|
+
|
|
241
|
+
private var badgeLabel: String? {
|
|
242
|
+
guard let label = item.badgeLabel else { return nil }
|
|
243
|
+
if let n = Int(label), n == 0 { return nil }
|
|
244
|
+
return label
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
var body: some View {
|
|
248
|
+
Icon(source: item.icon, size: iconSize, color: selected ? theme.colors.primary : theme.colors.text.default)
|
|
249
|
+
.overlay(alignment: .topTrailing) {
|
|
250
|
+
if let badgeLabel, !badgeLabel.isEmpty {
|
|
251
|
+
Badge(label: badgeLabel).offset(x: Spacing.S, y: -Spacing.XS)
|
|
252
|
+
} else if badgeLabel != nil || item.showDot {
|
|
253
|
+
BadgeDot(size: .small)
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
private struct DuoQrButton: View {
|
|
260
|
+
let data: BottomTabFloatingButton
|
|
261
|
+
let size: CGFloat
|
|
262
|
+
|
|
263
|
+
@Environment(\.appTheme) private var theme
|
|
264
|
+
|
|
265
|
+
var body: some View {
|
|
266
|
+
SwiftUI.Button(action: data.onPress) {
|
|
267
|
+
Circle()
|
|
268
|
+
.fill(theme.colors.primary)
|
|
269
|
+
.frame(width: size, height: size)
|
|
270
|
+
.overlay(Icon(source: data.icon, size: qrIconSize, color: Colors.black01))
|
|
271
|
+
}
|
|
272
|
+
.buttonStyle(.plain)
|
|
273
|
+
.accessibilityLabel(data.label)
|
|
274
|
+
}
|
|
275
|
+
}
|
|
@@ -19,8 +19,11 @@ public struct HeaderUserView: View {
|
|
|
19
19
|
|
|
20
20
|
private let avatarSize: CGFloat = Spacing.XXL // 32
|
|
21
21
|
|
|
22
|
+
@Environment(\.screenLayout) private var layout
|
|
23
|
+
|
|
24
|
+
// back button and toolkit share the bar unless the iPhone Duo side bar holds them
|
|
22
25
|
private var maxWidth: CGFloat {
|
|
23
|
-
|
|
26
|
+
layout.sideBar != nil ? layout.contentWidth - Spacing.M * 2 : layout.width - 172
|
|
24
27
|
}
|
|
25
28
|
|
|
26
29
|
public var body: some View {
|