@momo-kits/native-kits 0.300.3-debug → 0.300.4-debug
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +51 -7
- package/README.md +2 -2
- package/compose/build.gradle.kts +10 -2
- package/compose/build.gradle.kts.backup +8 -0
- package/compose/compose.podspec +1 -3
- package/compose/src/androidMain/kotlin/vn/momo/kits/const/HostFont.android.kt +4 -3
- package/compose/src/androidMain/kotlin/vn/momo/kits/platform/Platform.android.kt +2 -0
- package/compose/src/androidMain/kotlin/vn/momo/kits/platform/ReservedRegions.android.kt +6 -0
- 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/application/LiteScreen.kt +351 -111
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Badge.kt +20 -21
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Button.kt +2 -1
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Carousel.kt +4 -2
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Chip.kt +1 -1
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Icon.kt +1 -1
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Information.kt +1 -1
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Input.kt +9 -6
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputMoney.kt +11 -12
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputOTP.kt +4 -9
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputPhoneNumber.kt +2 -2
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputSearch.kt +10 -9
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputTextArea.kt +6 -6
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/PopupNotify.kt +55 -22
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/PopupPromotion.kt +12 -1
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/ProgressInfo.kt +7 -9
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Radio.kt +1 -1
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Slider.kt +13 -6
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Swipe.kt +13 -5
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Switch.kt +3 -2
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/TabView.kt +14 -6
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Tag.kt +19 -21
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Text.kt +2 -2
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Tooltip.kt +5 -2
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/DateTimePickerUtils.kt +3 -1
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/WheelPicker.kt +7 -15
- package/compose/src/commonMain/kotlin/vn/momo/kits/const/HostFont.kt +2 -1
- package/compose/src/commonMain/kotlin/vn/momo/kits/const/Typography.kt +20 -15
- package/compose/src/commonMain/kotlin/vn/momo/kits/layout/Item.kt +1 -2
- package/compose/src/commonMain/kotlin/vn/momo/kits/layout/ScreenLayout.kt +306 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/BottomSheet.kt +43 -4
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/ModalScreen.kt +8 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigation.kt +10 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/NavigationContainer.kt +6 -2
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigator.kt +6 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/StackScreen.kt +197 -60
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTab.kt +132 -83
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTabBar.kt +54 -62
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/DuoBarCompression.kt +97 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/DuoTabBar.kt +377 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/Header.kt +122 -24
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderBackground.kt +34 -14
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderJourney.kt +3 -3
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderLocation.kt +1 -2
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderRight.kt +113 -26
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderTitle.kt +2 -2
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderUser.kt +1 -2
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HelpCenterRequest.kt +37 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/SideBar.kt +192 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/SnackBar.kt +2 -1
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/ToolkitRequests.kt +8 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/platform/Platform.kt +3 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/platform/ReservedRegions.kt +20 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/utils/Utils.kt +13 -0
- package/compose/src/commonTest/kotlin/vn/momo/kits/navigation/component/HelpCenterRequestTest.kt +44 -0
- package/compose/src/commonTest/kotlin/vn/momo/kits/navigation/component/ToolkitRequestsTest.kt +15 -0
- 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 +4 -0
- package/compose/src/iosMain/kotlin/vn/momo/kits/platform/ReservedRegions.ios.kt +138 -0
- package/gradle/libs.versions.toml +3 -14
- package/gradle/wrapper/gradle-wrapper.properties +0 -2
- package/gradle.properties +3 -5
- package/ios/Application/ApplicationEnvironment.swift +24 -6
- package/ios/Application/DesignSystemKits.swift +100 -0
- package/ios/Application/DesignSystemKitsPayload.swift +68 -0
- package/ios/Application/MaxApi.swift +30 -3
- package/ios/Avatar/Avatar.swift +4 -3
- package/ios/Badge/Badge.swift +6 -4
- package/ios/Badge/BadgeDot.swift +1 -1
- package/ios/Badge/BadgeRibbon.swift +8 -2
- package/ios/Button/Button.swift +15 -12
- package/ios/CalculatorKeyboard/CalculatorKeyboard.swift +64 -18
- package/ios/Carousel/Carousel.swift +29 -12
- package/ios/Checkbox/Checkbox.swift +7 -3
- package/ios/Chip/Chip.swift +5 -4
- package/ios/Collapse/Collapse.swift +70 -19
- package/ios/Colors+Radius+Spacing/Colors.swift +10 -1
- package/ios/Colors+Radius+Spacing/Radius.swift +6 -0
- package/ios/DateTimePicker/DateTimePicker.swift +54 -20
- package/ios/DateTimePicker/WheelPicker.swift +103 -105
- package/ios/Divider/Divider.swift +37 -4
- package/ios/Extensions/Color++.swift +38 -9
- package/ios/Extensions/OptionalEnvironmentObject.swift +12 -0
- package/ios/Extensions/SkeletonUICompat.swift +11 -0
- package/ios/Icon/Icon.swift +21 -23
- package/ios/Icon/Icons.swift +1329 -0
- package/ios/IconButton/IconButton.swift +15 -7
- package/ios/Image/Image.swift +7 -23
- package/ios/Information/Information.swift +27 -9
- package/ios/Input/ErrorView.swift +2 -2
- package/ios/Input/Input.swift +171 -148
- package/ios/Input/InputPhoneNumber.swift +10 -9
- package/ios/Input/InputSearch.swift +107 -78
- package/ios/Input/InputTextArea.swift +133 -148
- package/ios/InputDropDown/InputDropDown.swift +10 -7
- package/ios/InputMoney/InputMoney.swift +32 -11
- package/ios/InputOTP/InputOTP.swift +22 -12
- package/ios/Layout/Card.swift +50 -0
- package/ios/Layout/GridContext.swift +187 -3
- package/ios/Layout/Item.swift +5 -4
- package/ios/Layout/ScreenLayout.swift +415 -0
- package/ios/Layout/Section.swift +4 -100
- package/ios/Loader/Loader.swift +5 -9
- package/ios/Lottie/LottieView.swift +14 -2
- package/ios/{Application/Navigation/Overplay → Navigation}/BottomSheet.swift +169 -29
- package/ios/Navigation/BottomTab/BottomTab.swift +332 -0
- package/ios/{Application/Navigation → Navigation}/BottomTab/BottomTabBar.swift +18 -7
- package/ios/Navigation/BottomTab/DuoBarCompression.swift +104 -0
- package/ios/Navigation/BottomTab/DuoTabBar.swift +329 -0
- package/ios/{Application → Navigation/Component}/FloatingButton.swift +43 -54
- package/ios/Navigation/Component/Header.swift +545 -0
- package/ios/Navigation/Component/HeaderBackground.swift +154 -0
- package/ios/Navigation/Component/HeaderJourney.swift +73 -0
- package/ios/Navigation/Component/HeaderLocation.swift +48 -0
- package/ios/Navigation/Component/HeaderRight.swift +425 -0
- package/ios/Navigation/Component/HeaderTitle.swift +36 -0
- package/ios/{Application/Navigation → Navigation/Component}/HeaderUser.swift +21 -24
- package/ios/Navigation/Component/SideBar.swift +183 -0
- package/ios/{Application/Navigation/Overplay → Navigation/Component}/SnackBar.swift +8 -8
- package/ios/Navigation/HostBackGestureBridge.swift +78 -0
- package/ios/{Application/Navigation/Overplay → Navigation}/ModalScreen.swift +9 -2
- package/ios/Navigation/Navigation.swift +204 -0
- package/ios/Navigation/NavigationContainer.swift +195 -0
- package/ios/{Application/Navigation → Navigation}/Navigator.swift +207 -107
- package/ios/Navigation/StackScreen.swift +930 -0
- package/ios/Navigation/StackTransition.swift +400 -0
- package/ios/Navigation/SwipeBackHaptic.swift +33 -0
- package/ios/{Application/Navigation → Navigation}/Tracking/ScreenTracker.swift +12 -5
- package/ios/OTPKeyboard/OTPKeyboard.swift +8 -11
- package/ios/Pagination/PaginationScroll.swift +2 -2
- package/ios/Popup/PopupDisplay.swift +72 -135
- package/ios/Popup/PopupInput.swift +10 -13
- package/ios/Popup/PopupLayout.swift +138 -0
- package/ios/Popup/PopupNotify.swift +59 -69
- package/ios/Popup/PopupPromotion.swift +31 -47
- package/ios/PopupView/FullscreenPopup.swift +13 -19
- package/ios/PopupView/PopupView.swift +1 -1
- package/ios/PopupView/Utils++.swift +0 -8
- package/ios/ProgressInfo/ProgressInfo.swift +4 -2
- package/ios/Radio/Radio.swift +2 -1
- package/ios/Rating/Rating.swift +9 -1
- package/ios/Resources/dot_loading.json +558 -0
- package/ios/Resources/lottie_circle_loader.json +1 -0
- package/ios/ScrollIndicator/ScrollIndicator.swift +11 -10
- package/ios/Skeleton/Skeleton.swift +22 -27
- package/ios/Slider/Slider.swift +16 -18
- package/ios/Stepper/Stepper.swift +7 -3
- package/ios/Steps/Steps.swift +8 -6
- package/ios/SuggestAction/SuggestAction.swift +5 -0
- package/ios/Swipeable/SwipeCell.swift +10 -6
- package/ios/Switch/Switch.swift +22 -20
- package/ios/TabView/TabView.swift +102 -21
- package/ios/Tag/Tag.swift +15 -4
- package/ios/Template/Logo/Logo.swift +2 -1
- package/ios/Template/TrustBanner/TrustBanner.swift +109 -55
- package/ios/Title/Title.swift +6 -17
- package/ios/Tooltip/Tooltip.swift +29 -14
- package/ios/Typography/FontScale.swift +17 -14
- package/ios/Typography/Text.swift +156 -20
- package/ios/Typography/Typography.swift +2 -2
- package/ios/Uploader/Uploader.swift +3 -1
- package/ios/native-kits.podspec +2 -3
- package/ios-demo/MoMoUIKitsDemo.podspec +1 -3
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/ContentView.swift +1 -1
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/DuoTabDemo.swift +80 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/Home.swift +2 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/IPhoneDuoDemo.swift +173 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/NavigationDemo.swift +77 -1
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PopupDisplayDemo.swift +1 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PopupNotifyDemo.swift +2 -2
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PopupPromotionDemo.swift +2 -0
- package/package.json +1 -1
- package/publish.sh +19 -2
- package/scripts/gen-ios-icons.mjs +36 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/application/AnimationSearchInput.kt +0 -52
- 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/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/useHeaderSearchAnimation.kt +0 -69
- 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/Application/Components.swift +0 -270
- package/ios/Application/ComposeApi.swift +0 -22
- package/ios/Application/HeaderRight.swift +0 -306
- package/ios/Application/Navigation/BottomTab/BottomTab.swift +0 -104
- package/ios/Application/Navigation/HeaderBackProps.swift +0 -66
- package/ios/Application/Navigation/HeaderTitle.swift +0 -57
- package/ios/Application/Navigation/NavigationContainer.swift +0 -213
- package/ios/Application/Navigation/NavigationOptions.swift +0 -109
- package/ios/Application/Navigation/NavigatorManager.swift +0 -22
- package/ios/Application/Navigation/component/Header.swift +0 -178
- package/ios/Application/Navigation/component/HeaderBackground.swift +0 -83
- package/ios/Application/Navigation/component/HeaderColor.swift +0 -65
- package/ios/Application/Navigation/component/HeaderRightWidthKey.swift +0 -11
- package/ios/Application/Navigation/component/NavigationFooter.swift +0 -90
- package/ios/Application/Navigation/component/NavigationHeaderRight.swift +0 -196
- package/ios/Application/Screen.swift +0 -250
- package/ios/Application/StackScreen.swift +0 -492
- package/ios/Typography/FontScaleStore.swift +0 -58
- package/ios/Typography/KitDesignSystem.swift +0 -17
- /package/ios/{Application/Navigation → Navigation}/BottomTab/CurvedContainer.swift +0 -0
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// BottomTabBar.swift
|
|
3
3
|
// MoMoUIKits
|
|
4
4
|
//
|
|
5
|
-
// SwiftUI mirror of Compose's `
|
|
5
|
+
// SwiftUI mirror of Compose's `navigation/bottomtab/BottomTabBar.kt`.
|
|
6
6
|
// Renders the row of tab items, an animated selection indicator, a top shadow,
|
|
7
7
|
// badges/dots, and an optional center floating button (with the curved notch).
|
|
8
8
|
//
|
|
@@ -20,6 +20,7 @@ public struct BottomTabItem: Identifiable {
|
|
|
20
20
|
public let showDot: Bool
|
|
21
21
|
public let badgeLabel: String?
|
|
22
22
|
public let options: NavigationOptions?
|
|
23
|
+
public let initialParams: Any?
|
|
23
24
|
public let screen: () -> AnyView
|
|
24
25
|
|
|
25
26
|
public init(
|
|
@@ -29,6 +30,7 @@ public struct BottomTabItem: Identifiable {
|
|
|
29
30
|
showDot: Bool = false,
|
|
30
31
|
badgeLabel: String? = nil,
|
|
31
32
|
options: NavigationOptions? = nil,
|
|
33
|
+
initialParams: Any? = nil,
|
|
32
34
|
@ViewBuilder screen: @escaping () -> some View
|
|
33
35
|
) {
|
|
34
36
|
self.name = name
|
|
@@ -37,6 +39,7 @@ public struct BottomTabItem: Identifiable {
|
|
|
37
39
|
self.showDot = showDot
|
|
38
40
|
self.badgeLabel = badgeLabel
|
|
39
41
|
self.options = options
|
|
42
|
+
self.initialParams = initialParams
|
|
40
43
|
self.screen = { AnyView(screen()) }
|
|
41
44
|
}
|
|
42
45
|
}
|
|
@@ -65,7 +68,11 @@ struct BottomTabBar: View {
|
|
|
65
68
|
let selectedIndex: Int
|
|
66
69
|
let onTabSelected: (Int) -> Void
|
|
67
70
|
|
|
68
|
-
private var
|
|
71
|
+
@Environment(\.screenLayout) private var layout
|
|
72
|
+
@Environment(\.appTheme) private var theme
|
|
73
|
+
|
|
74
|
+
// the bar sits between the side safe areas
|
|
75
|
+
private var screenWidth: CGFloat { layout.width - layout.insets.leading - layout.insets.trailing }
|
|
69
76
|
private var mid: Int { items.count / 2 }
|
|
70
77
|
|
|
71
78
|
private var tabWidth: CGFloat {
|
|
@@ -95,11 +102,11 @@ struct BottomTabBar: View {
|
|
|
95
102
|
}
|
|
96
103
|
.frame(maxWidth: .infinity)
|
|
97
104
|
.frame(height: BOTTOM_TAB_BAR_HEIGHT)
|
|
98
|
-
.background(
|
|
105
|
+
.background(theme.colors.background.surface.ignoresSafeArea(edges: .horizontal))
|
|
99
106
|
|
|
100
107
|
// Animated selection indicator.
|
|
101
108
|
RoundedRectangle(cornerRadius: 2)
|
|
102
|
-
.fill(
|
|
109
|
+
.fill(theme.colors.primary)
|
|
103
110
|
.frame(width: 44, height: 2)
|
|
104
111
|
.offset(x: indicatorOffset, y: -BOTTOM_TAB_BAR_HEIGHT + 2)
|
|
105
112
|
.animation(.easeInOut(duration: 0.25), value: selectedIndex)
|
|
@@ -140,7 +147,9 @@ private struct TabBarItem: View {
|
|
|
140
147
|
let selected: Bool
|
|
141
148
|
let onClick: () -> Void
|
|
142
149
|
|
|
143
|
-
private var
|
|
150
|
+
@Environment(\.appTheme) private var theme
|
|
151
|
+
|
|
152
|
+
private var tintColor: Color { selected ? theme.colors.primary : theme.colors.text.hint }
|
|
144
153
|
|
|
145
154
|
/// Mirrors Compose's `formatLabel`: hide a badge that is the number "0".
|
|
146
155
|
private var badgeLabel: String? {
|
|
@@ -192,18 +201,20 @@ private struct TabBarItem: View {
|
|
|
192
201
|
private struct CenterFloatingButton: View {
|
|
193
202
|
let data: BottomTabFloatingButton
|
|
194
203
|
|
|
204
|
+
@Environment(\.appTheme) private var theme
|
|
205
|
+
|
|
195
206
|
var body: some View {
|
|
196
207
|
VStack(spacing: 0) {
|
|
197
208
|
CurvedContainer()
|
|
198
209
|
Circle()
|
|
199
|
-
.fill(
|
|
210
|
+
.fill(theme.colors.primary)
|
|
200
211
|
.frame(width: 48, height: 48)
|
|
201
212
|
.overlay(Icon(source: data.icon, size: 28, color: .white))
|
|
202
213
|
Spacer().frame(height: 4)
|
|
203
214
|
MomoText(data.label, typography: .labelXsMedium, color: Colors.black01, textAlign: .center, maxLines: 1)
|
|
204
215
|
.frame(maxWidth: .infinity)
|
|
205
216
|
.frame(height: 14)
|
|
206
|
-
.background(
|
|
217
|
+
.background(theme.colors.primary)
|
|
207
218
|
.clipShape(RoundedRectangle(cornerRadius: Radius.XL))
|
|
208
219
|
}
|
|
209
220
|
.frame(width: bottomTabFloatingButtonWidth)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
//
|
|
2
|
+
// DuoBarCompression.swift
|
|
3
|
+
// MoMoUIKits
|
|
4
|
+
//
|
|
5
|
+
// Port of RN `BottomTab/DuoBarCompression.tsx` (HIG, Designing for iPhone Duo: Vertical controls):
|
|
6
|
+
// keeps the side bar's toolbar and tab bar clear of each other.
|
|
7
|
+
//
|
|
8
|
+
|
|
9
|
+
import SwiftUI
|
|
10
|
+
|
|
11
|
+
/// iPhone Duo: which side bar bar starts compressed when the strip cannot hold both whole.
|
|
12
|
+
public enum DuoBarCompressionBehavior {
|
|
13
|
+
case toolbar, tabBar
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
enum DuoBar {
|
|
17
|
+
case toolbar, tabBar
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
struct DuoBarCompression {
|
|
21
|
+
var toolbarCompressed = false
|
|
22
|
+
var tabBarCompressed = false
|
|
23
|
+
/// The open rail needs the toolbar button slot too: only Back stays.
|
|
24
|
+
var toolbarHidden = false
|
|
25
|
+
var expandToolbar: () -> Void = {}
|
|
26
|
+
var expandTabBar: () -> Void = {}
|
|
27
|
+
/// A rail opened from the compressed state closes once a tab is picked.
|
|
28
|
+
var onTabSelected: () -> Void = {}
|
|
29
|
+
/// Side bar toolkits report their full height so the tab bar knows what is left.
|
|
30
|
+
var setToolbarHeight: (CGFloat) -> Void = { _ in }
|
|
31
|
+
|
|
32
|
+
/// Space kept between the top and the bottom groups (HIG Vertical controls).
|
|
33
|
+
static let groupGap = Spacing.M
|
|
34
|
+
/// Back, then the right toolkit: help + home capsule.
|
|
35
|
+
static let defaultToolbarHeight = DuoMetrics.controlSize + Spacing.M + 2 * DuoMetrics.itemPitch
|
|
36
|
+
/// Back, then the single button the toolbar folds into.
|
|
37
|
+
private static let compressedToolbarHeight = 2 * DuoMetrics.controlSize + Spacing.M
|
|
38
|
+
|
|
39
|
+
/// - both fit: both stay whole
|
|
40
|
+
/// - one fits whole beside the other compressed: one is compressed, tapping it swaps them
|
|
41
|
+
/// - otherwise both rest compressed; tapping one opens it for a moment (the rail closes
|
|
42
|
+
/// again once a tab is picked)
|
|
43
|
+
static func resolve(
|
|
44
|
+
layout: ScreenLayout,
|
|
45
|
+
tabCount: Int,
|
|
46
|
+
hasQr: Bool,
|
|
47
|
+
toolbarHeight: CGFloat,
|
|
48
|
+
expanded: Binding<DuoBar>,
|
|
49
|
+
opened: Binding<DuoBar?>,
|
|
50
|
+
setToolbarHeight: @escaping (CGFloat) -> Void
|
|
51
|
+
) -> DuoBarCompression {
|
|
52
|
+
guard let sideBar = layout.sideBar else { return DuoBarCompression() }
|
|
53
|
+
let room = layout.height - sideBar.bottom - sideBar.top - groupGap
|
|
54
|
+
let tabBar = duoRailHeight(itemCount: tabCount, hasQr: hasQr)
|
|
55
|
+
let compressedTabBar = duoRailHeight(itemCount: 1, hasQr: hasQr, compressed: true)
|
|
56
|
+
func fits(_ toolbar: CGFloat, _ tabs: CGFloat) -> Bool { toolbar + tabs <= room }
|
|
57
|
+
|
|
58
|
+
if fits(toolbarHeight, tabBar) {
|
|
59
|
+
return DuoBarCompression(setToolbarHeight: setToolbarHeight)
|
|
60
|
+
}
|
|
61
|
+
if fits(compressedToolbarHeight, tabBar) && fits(toolbarHeight, compressedTabBar) {
|
|
62
|
+
return DuoBarCompression(
|
|
63
|
+
toolbarCompressed: expanded.wrappedValue == .tabBar,
|
|
64
|
+
tabBarCompressed: expanded.wrappedValue == .toolbar,
|
|
65
|
+
expandToolbar: { expanded.wrappedValue = .toolbar },
|
|
66
|
+
expandTabBar: { expanded.wrappedValue = .tabBar },
|
|
67
|
+
setToolbarHeight: setToolbarHeight
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
let expandToolbar = { opened.wrappedValue = .toolbar }
|
|
72
|
+
let expandTabBar = { opened.wrappedValue = .tabBar }
|
|
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 {
|
|
75
|
+
return DuoBarCompression(
|
|
76
|
+
toolbarCompressed: true,
|
|
77
|
+
tabBarCompressed: false,
|
|
78
|
+
toolbarHidden: true,
|
|
79
|
+
expandToolbar: expandToolbar,
|
|
80
|
+
expandTabBar: expandTabBar,
|
|
81
|
+
onTabSelected: { opened.wrappedValue = nil },
|
|
82
|
+
setToolbarHeight: setToolbarHeight
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
return DuoBarCompression(
|
|
86
|
+
toolbarCompressed: opened.wrappedValue != .toolbar,
|
|
87
|
+
tabBarCompressed: true,
|
|
88
|
+
expandToolbar: expandToolbar,
|
|
89
|
+
expandTabBar: expandTabBar,
|
|
90
|
+
setToolbarHeight: setToolbarHeight
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private struct DuoBarCompressionKey: EnvironmentKey {
|
|
96
|
+
static let defaultValue = DuoBarCompression()
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
extension EnvironmentValues {
|
|
100
|
+
var duoBarCompression: DuoBarCompression {
|
|
101
|
+
get { self[DuoBarCompressionKey.self] }
|
|
102
|
+
set { self[DuoBarCompressionKey.self] = newValue }
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -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
|
|
63
|
-
|
|
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
|
-
|
|
125
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
153
|
-
|
|
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
|
}
|