@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
|
// BottomSheet.swift
|
|
3
3
|
// MoMoUIKits
|
|
4
4
|
//
|
|
5
|
-
// SwiftUI mirror of Compose's `
|
|
5
|
+
// SwiftUI mirror of Compose's `navigation/BottomSheet.kt`.
|
|
6
6
|
// A sheet that slides up from the bottom over a dimmed scrim, with a drag
|
|
7
7
|
// handle, a header (title or custom), a divider and the content body.
|
|
8
8
|
// Dragging down past a threshold (or tapping the scrim) dismisses it.
|
|
@@ -35,16 +35,21 @@ public struct BottomSheetHeader {
|
|
|
35
35
|
|
|
36
36
|
// MARK: - Bottom sheet view
|
|
37
37
|
|
|
38
|
-
@available(iOS 16.0, *)
|
|
39
38
|
struct BottomSheetView<Content: View>: View {
|
|
39
|
+
let overplayId: UUID
|
|
40
40
|
let header: BottomSheetHeader
|
|
41
41
|
let isSurface: Bool
|
|
42
42
|
let barrierDismissible: Bool
|
|
43
|
+
var draggable: Bool = true
|
|
44
|
+
/// Keeps the content above the home indicator (Compose `AppNavigationBar` padding).
|
|
45
|
+
var useBottomInset: Bool = true
|
|
46
|
+
var footer: (() -> AnyView)? = nil
|
|
43
47
|
let content: () -> Content
|
|
44
48
|
|
|
45
49
|
@EnvironmentObject private var navigator: Navigator
|
|
46
50
|
@Environment(\.appTheme) private var theme
|
|
47
51
|
@Environment(\.applicationEnvironment) private var appEnv
|
|
52
|
+
@Environment(\.screenLayout) private var layout
|
|
48
53
|
|
|
49
54
|
/// Vertical translation of the sheet; starts off-screen and animates to 0.
|
|
50
55
|
@State private var sheetOffset: CGFloat = UIScreen.main.bounds.height
|
|
@@ -52,6 +57,7 @@ struct BottomSheetView<Content: View>: View {
|
|
|
52
57
|
@State private var backgroundAlpha: CGFloat = 0
|
|
53
58
|
@State private var sheetHeight: CGFloat = 0
|
|
54
59
|
@State private var didAppear = false
|
|
60
|
+
@State private var isClosing = false
|
|
55
61
|
@State private var keyboardHeight: CGFloat = 0
|
|
56
62
|
/// Live drag offset, isolated from `sheetOffset` so a touch-move only invalidates
|
|
57
63
|
/// this gesture-scoped value instead of forcing the whole body (including
|
|
@@ -66,8 +72,35 @@ struct BottomSheetView<Content: View>: View {
|
|
|
66
72
|
/// steady-state (fully open) scrim peaks at 0.6, matching ModalScreen's peak.
|
|
67
73
|
private let maxDynamicAlpha: CGFloat = 0.3
|
|
68
74
|
|
|
69
|
-
|
|
70
|
-
private var
|
|
75
|
+
// UIScreen stays on the outer display on iPhone Duo, even when unfolded
|
|
76
|
+
private var screenHeight: CGFloat { isDuo ? layout.height : UIScreen.main.bounds.height }
|
|
77
|
+
|
|
78
|
+
private var isDuo: Bool { layout.isDuoDevice }
|
|
79
|
+
// iPhone Duo inner display: center the sheet instead of spanning the width
|
|
80
|
+
private var isDuoCentered: Bool { isDuo && layout.width >= 600 }
|
|
81
|
+
|
|
82
|
+
/// Keeps the portrait width in landscape, like system sheets.
|
|
83
|
+
private var duoCenteredWidth: CGFloat {
|
|
84
|
+
min(
|
|
85
|
+
min(layout.width, layout.height) - Spacing.L * 2,
|
|
86
|
+
layout.width - max(layout.insets.leading, layout.insets.trailing, Spacing.L) * 2
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/// Landscape side buttons span the full height, so the content is inset; in portrait the
|
|
91
|
+
/// side status bar only covers the top corner.
|
|
92
|
+
private var sideInsetLeading: CGFloat {
|
|
93
|
+
isDuo && !isDuoCentered && layout.width > layout.height ? layout.insets.leading : 0
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private var sideInsetTrailing: CGFloat {
|
|
97
|
+
isDuo && !isDuoCentered && layout.width > layout.height ? layout.insets.trailing : 0
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private var maxHeight: CGFloat {
|
|
101
|
+
isDuo ? layout.height - max(layout.duoTopInset, Spacing.XL) : screenHeight - 90
|
|
102
|
+
}
|
|
103
|
+
private var bottomInset: CGFloat { useBottomInset ? safeAreaBottomInset() : 0 }
|
|
71
104
|
/// While the keyboard is up, it already covers the home-indicator area, so it
|
|
72
105
|
/// replaces (rather than adds to) the usual bottom-inset filler.
|
|
73
106
|
private var effectiveBottomInset: CGFloat { keyboardHeight > 0 ? keyboardHeight : bottomInset }
|
|
@@ -110,7 +143,7 @@ struct BottomSheetView<Content: View>: View {
|
|
|
110
143
|
.onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillChangeFrameNotification)) { notification in
|
|
111
144
|
guard let endFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return }
|
|
112
145
|
withAnimation(.easeOut(duration: 0.25)) {
|
|
113
|
-
keyboardHeight =
|
|
146
|
+
keyboardHeight = screenHeight - endFrame.origin.y
|
|
114
147
|
}
|
|
115
148
|
}
|
|
116
149
|
.onAppear {
|
|
@@ -128,33 +161,48 @@ struct BottomSheetView<Content: View>: View {
|
|
|
128
161
|
|
|
129
162
|
private var sheet: some View {
|
|
130
163
|
VStack(spacing: 0) {
|
|
131
|
-
// Drag handle + header (the draggable region).
|
|
132
164
|
VStack(spacing: 0) {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
.
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
.frame(height:
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
165
|
+
// Drag handle + header (the draggable region).
|
|
166
|
+
VStack(spacing: 0) {
|
|
167
|
+
Spacer().frame(height: 8)
|
|
168
|
+
Capsule()
|
|
169
|
+
.fill(Colors.black06)
|
|
170
|
+
.frame(width: 40, height: 4)
|
|
171
|
+
Spacer().frame(height: 4)
|
|
172
|
+
headerView
|
|
173
|
+
.frame(height: 56)
|
|
174
|
+
.padding(.horizontal, Spacing.M)
|
|
175
|
+
.padding(.leading, sideInsetLeading)
|
|
176
|
+
.padding(.trailing, sideInsetTrailing)
|
|
177
|
+
}
|
|
178
|
+
.frame(maxWidth: .infinity)
|
|
179
|
+
.frame(height: 72)
|
|
180
|
+
.contentShape(Rectangle())
|
|
181
|
+
.gesture(dragGesture, including: draggable ? .all : .subviews)
|
|
146
182
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
183
|
+
Rectangle()
|
|
184
|
+
.fill(theme.colors.border.default)
|
|
185
|
+
.frame(height: 1)
|
|
150
186
|
|
|
151
|
-
|
|
187
|
+
content()
|
|
188
|
+
.padding(.leading, sideInsetLeading)
|
|
189
|
+
.padding(.trailing, sideInsetTrailing)
|
|
190
|
+
// a tall sheet keeps its header (and footer) and gives the content what is left
|
|
191
|
+
.layoutPriority(-1)
|
|
192
|
+
|
|
193
|
+
if let footer {
|
|
194
|
+
BottomSheetFooter(content: footer)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
// The filler stays outside the cap, which shrinks by it instead: the sheet still ends at
|
|
198
|
+
// `maxHeight`, and the iOS 15 fallback never measures the keyboard as content.
|
|
199
|
+
.modifier(CapHeight(maxHeight: maxHeight - effectiveBottomInset))
|
|
152
200
|
|
|
153
|
-
backgroundColor
|
|
201
|
+
(footer != nil ? theme.colors.background.surface : backgroundColor)
|
|
154
202
|
.frame(height: effectiveBottomInset)
|
|
155
203
|
}
|
|
156
|
-
.frame(maxWidth: .infinity)
|
|
157
|
-
.
|
|
204
|
+
.frame(maxWidth: isDuoCentered ? duoCenteredWidth : .infinity)
|
|
205
|
+
.clipped()
|
|
158
206
|
.background(
|
|
159
207
|
GeometryReader { geo in
|
|
160
208
|
Color.clear.onAppear { sheetHeight = geo.size.height }
|
|
@@ -198,14 +246,16 @@ struct BottomSheetView<Content: View>: View {
|
|
|
198
246
|
// same bottom-anchored top edge — is still in flight doesn't fight a
|
|
199
247
|
// moving target. Without this, the live drag value chases an animated
|
|
200
248
|
// one, producing a couple of corrective jumps before it settles.
|
|
201
|
-
|
|
249
|
+
// A closing sheet keeps going down.
|
|
250
|
+
if sheetOffset != 0 && !isClosing {
|
|
202
251
|
sheetOffset = 0
|
|
203
252
|
}
|
|
204
253
|
}
|
|
205
254
|
.updating($dragTranslation) { value, state, _ in
|
|
206
|
-
state = max(value.translation.height, 0)
|
|
255
|
+
state = isClosing ? 0 : max(value.translation.height, 0)
|
|
207
256
|
}
|
|
208
257
|
.onEnded { value in
|
|
258
|
+
guard !isClosing else { return }
|
|
209
259
|
let finalOffset = max(value.translation.height, 0)
|
|
210
260
|
// Capture into persisted state BEFORE @GestureState resets to 0,
|
|
211
261
|
// avoiding a one-frame snap back to rest before close()'s own
|
|
@@ -233,6 +283,8 @@ struct BottomSheetView<Content: View>: View {
|
|
|
233
283
|
/// Mirrors Compose's `closeEvent`: slide out over 200ms, then fade the scrim
|
|
234
284
|
/// out over 100ms only after the slide finishes, then dismiss.
|
|
235
285
|
private func close() {
|
|
286
|
+
guard !isClosing else { return }
|
|
287
|
+
isClosing = true
|
|
236
288
|
withAnimation(.timingCurve(0.3, 0, 0.8, 0.15, duration: 0.2)) {
|
|
237
289
|
sheetOffset = max(sheetHeight, screenHeight)
|
|
238
290
|
}
|
|
@@ -242,12 +294,100 @@ struct BottomSheetView<Content: View>: View {
|
|
|
242
294
|
}
|
|
243
295
|
}
|
|
244
296
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
|
245
|
-
navigator.finishOverplayDismiss()
|
|
297
|
+
navigator.finishOverplayDismiss(id: overplayId)
|
|
246
298
|
}
|
|
247
299
|
}
|
|
248
300
|
}
|
|
249
301
|
|
|
302
|
+
/// Compose's `heightIn(max:)`. `.frame(maxHeight:)` grows to the cap whenever the proposal is
|
|
303
|
+
/// larger, leaving a gap above the header; this sizes the sheet to its content and only caps it
|
|
304
|
+
/// when taller.
|
|
305
|
+
private struct CapHeight: ViewModifier {
|
|
306
|
+
let maxHeight: CGFloat
|
|
307
|
+
|
|
308
|
+
func body(content: Content) -> some View {
|
|
309
|
+
if #available(iOS 16.0, *) {
|
|
310
|
+
CapHeightLayout(maxHeight: maxHeight) { content }
|
|
311
|
+
} else {
|
|
312
|
+
content.modifier(CapHeightFallback(maxHeight: maxHeight))
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/// iOS 15 has no `Layout`: the sheet hugs its ideal height until that outgrows the cap, then is
|
|
318
|
+
/// held at the cap. A capped sheet can't read its ideal height (that needs a second copy of the
|
|
319
|
+
/// content), so it is re-measured only when the cap grows, e.g. once the keyboard goes down.
|
|
320
|
+
private struct CapHeightFallback: ViewModifier {
|
|
321
|
+
let maxHeight: CGFloat
|
|
322
|
+
@State private var capped = false
|
|
323
|
+
@State private var idealHeight: CGFloat = 0
|
|
324
|
+
|
|
325
|
+
func body(content: Content) -> some View {
|
|
326
|
+
content
|
|
327
|
+
// both modifiers stay in place whatever `capped` is, so the content keeps its identity
|
|
328
|
+
.fixedSize(horizontal: false, vertical: !capped)
|
|
329
|
+
.background(
|
|
330
|
+
GeometryReader { proxy in
|
|
331
|
+
Color.clear
|
|
332
|
+
.onAppear { measure(proxy.size.height) }
|
|
333
|
+
.onChange(of: proxy.size.height) { measure($0) }
|
|
334
|
+
}
|
|
335
|
+
)
|
|
336
|
+
.frame(height: capped ? maxHeight : nil, alignment: .top)
|
|
337
|
+
.onChange(of: maxHeight) { cap in
|
|
338
|
+
if capped {
|
|
339
|
+
// the ideal height was over the old cap, so only a taller cap can release it
|
|
340
|
+
if cap > idealHeight { capped = false }
|
|
341
|
+
} else if idealHeight > cap {
|
|
342
|
+
capped = true
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
private func measure(_ height: CGFloat) {
|
|
348
|
+
guard !capped else { return }
|
|
349
|
+
idealHeight = height
|
|
350
|
+
if height > maxHeight { capped = true }
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
250
354
|
@available(iOS 16.0, *)
|
|
355
|
+
private struct CapHeightLayout: Layout {
|
|
356
|
+
let maxHeight: CGFloat
|
|
357
|
+
|
|
358
|
+
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
|
|
359
|
+
guard let subview = subviews.first else { return .zero }
|
|
360
|
+
let cap = min(maxHeight, proposal.height ?? .infinity)
|
|
361
|
+
// ideal first: a scroll view would otherwise fill whatever height it is offered
|
|
362
|
+
let ideal = subview.sizeThatFits(ProposedViewSize(width: proposal.width, height: nil))
|
|
363
|
+
return subview.sizeThatFits(ProposedViewSize(width: proposal.width, height: min(ideal.height, cap)))
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
|
|
367
|
+
subviews.first?.place(at: bounds.origin, proposal: ProposedViewSize(bounds.size))
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/// Mirrors Compose's `BottomSheetFooter`: a surface band with a soft shadow cast on the content.
|
|
372
|
+
private struct BottomSheetFooter: View {
|
|
373
|
+
let content: () -> AnyView
|
|
374
|
+
|
|
375
|
+
@Environment(\.appTheme) private var theme
|
|
376
|
+
|
|
377
|
+
var body: some View {
|
|
378
|
+
content()
|
|
379
|
+
.frame(maxWidth: .infinity)
|
|
380
|
+
.padding(.vertical, Spacing.S)
|
|
381
|
+
.background(theme.colors.background.surface)
|
|
382
|
+
.overlay(alignment: .top) {
|
|
383
|
+
LinearGradient(colors: [.clear, Color.black.opacity(0.05)], startPoint: .top, endPoint: .bottom)
|
|
384
|
+
.frame(height: 6)
|
|
385
|
+
.offset(y: -6)
|
|
386
|
+
.allowsHitTesting(false)
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
251
391
|
private func safeAreaBottomInset() -> CGFloat {
|
|
252
392
|
let bottomInset = UIApplication.shared.connectedScenes
|
|
253
393
|
.compactMap { ($0 as? UIWindowScene)?.keyWindow?.safeAreaInsets.bottom }
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
//
|
|
2
|
+
// BottomTab.swift
|
|
3
|
+
// MoMoUIKits
|
|
4
|
+
//
|
|
5
|
+
// SwiftUI mirror of Compose's `navigation/bottomtab/BottomTab.kt`: tabbed screens, each in
|
|
6
|
+
// `StackScreen` chrome built from its own `Navigation`, over a `BottomTabBar` (or the iPhone Duo
|
|
7
|
+
// rail / floating bar). Switching tabs cross-fades + scales the content.
|
|
8
|
+
//
|
|
9
|
+
|
|
10
|
+
import SwiftUI
|
|
11
|
+
import UIKit
|
|
12
|
+
|
|
13
|
+
public struct BottomTab: View {
|
|
14
|
+
private let items: [BottomTabItem]
|
|
15
|
+
private let floatingButton: BottomTabFloatingButton?
|
|
16
|
+
private let compressionBehavior: DuoBarCompressionBehavior
|
|
17
|
+
private let onTabPress: ((_ index: Int, _ switchTab: @escaping () -> Void) -> Void)?
|
|
18
|
+
private let backToInitialTab: Bool
|
|
19
|
+
|
|
20
|
+
@EnvironmentObject private var navigator: Navigator
|
|
21
|
+
/// The screen hosting the tabs (Compose's `LocalNavigation` in BottomTab).
|
|
22
|
+
@OptionalEnvironmentObject private var hostNavigation: Navigation?
|
|
23
|
+
@Environment(\.screenLayout) private var layout
|
|
24
|
+
@Environment(\.appTheme) private var theme
|
|
25
|
+
@StateObject private var tabNavigations = TabNavigations()
|
|
26
|
+
/// Compose's `startRoute`: picked once from `initialRouteName`.
|
|
27
|
+
@State private var startIndex: Int
|
|
28
|
+
@State private var selectedIndex: Int
|
|
29
|
+
@State private var toolbarHeight = DuoBarCompression.defaultToolbarHeight
|
|
30
|
+
@State private var expandedBar: DuoBar?
|
|
31
|
+
@State private var openedBar: DuoBar?
|
|
32
|
+
|
|
33
|
+
/// `compressionBehavior`: on iPhone Duo, when the side bar can't hold both bars, which one
|
|
34
|
+
/// starts compressed; tapping the compressed one swaps them.
|
|
35
|
+
/// `onTabPress` gets every tab press with the switch to perform, e.g. after a login gate.
|
|
36
|
+
/// `backToInitialTab`: back off another tab returns to the initial one before leaving the screen.
|
|
37
|
+
public init(
|
|
38
|
+
items: [BottomTabItem],
|
|
39
|
+
floatingButton: BottomTabFloatingButton? = nil,
|
|
40
|
+
compressionBehavior: DuoBarCompressionBehavior = .toolbar,
|
|
41
|
+
initialRouteName: String? = nil,
|
|
42
|
+
onTabPress: ((_ index: Int, _ switchTab: @escaping () -> Void) -> Void)? = nil,
|
|
43
|
+
backToInitialTab: Bool = true
|
|
44
|
+
) {
|
|
45
|
+
self.items = items
|
|
46
|
+
self.floatingButton = floatingButton
|
|
47
|
+
self.compressionBehavior = compressionBehavior
|
|
48
|
+
self.onTabPress = onTabPress
|
|
49
|
+
self.backToInitialTab = backToInitialTab
|
|
50
|
+
let start = items.firstIndex { $0.name == initialRouteName } ?? 0
|
|
51
|
+
_startIndex = State(initialValue: start)
|
|
52
|
+
_selectedIndex = State(initialValue: start)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private var bottomInset: CGFloat { safeAreaBottomInset() }
|
|
56
|
+
// the Duo bars overlay the content or sit in the side strip, so only the home indicator is kept clear
|
|
57
|
+
private var reservedBottom: CGFloat {
|
|
58
|
+
layout.isIPhoneDuo ? layout.insets.bottom : BOTTOM_TAB_BAR_HEIGHT + bottomInset + Spacing.S
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
private var compression: DuoBarCompression {
|
|
62
|
+
DuoBarCompression.resolve(
|
|
63
|
+
layout: layout,
|
|
64
|
+
tabCount: items.count,
|
|
65
|
+
hasQr: floatingButton != nil,
|
|
66
|
+
toolbarHeight: toolbarHeight,
|
|
67
|
+
// 'toolbar' compression keeps the tab bar whole until the toolbar is tapped
|
|
68
|
+
expanded: Binding(
|
|
69
|
+
get: { expandedBar ?? (compressionBehavior == .toolbar ? .tabBar : .toolbar) },
|
|
70
|
+
set: { expandedBar = $0 }
|
|
71
|
+
),
|
|
72
|
+
opened: $openedBar,
|
|
73
|
+
setToolbarHeight: { height in
|
|
74
|
+
if toolbarHeight != height { toolbarHeight = height }
|
|
75
|
+
}
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private func duoInsets(_ compression: DuoBarCompression) -> DuoTabBarInsets {
|
|
80
|
+
layout.duoTabBarInsets(
|
|
81
|
+
itemCount: items.count,
|
|
82
|
+
hasFloatingButton: floatingButton != nil,
|
|
83
|
+
tabBarCompressed: compression.tabBarCompressed
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
public var body: some View {
|
|
88
|
+
let compression = compression
|
|
89
|
+
ZStack(alignment: .bottom) {
|
|
90
|
+
// Tab content area, inset above the bar.
|
|
91
|
+
TabHost(selectedIndex: selectedIndex, count: items.count) { index in AnyView(tabScreen(for: index)) }
|
|
92
|
+
// each tab hosts itself, so it needs the window's safe area to draw its header under the status bar
|
|
93
|
+
.ignoresSafeArea(.container, edges: [.top, .horizontal])
|
|
94
|
+
.padding(.bottom, reservedBottom)
|
|
95
|
+
.environment(\.duoTabBarInsets, duoInsets(compression))
|
|
96
|
+
.environment(\.duoBarCompression, compression)
|
|
97
|
+
|
|
98
|
+
if layout.isIPhoneDuo {
|
|
99
|
+
DuoTabBar(
|
|
100
|
+
items: items,
|
|
101
|
+
floatingButton: floatingButton,
|
|
102
|
+
selectedIndex: selectedIndex,
|
|
103
|
+
onTabSelected: selectTab,
|
|
104
|
+
compression: compression
|
|
105
|
+
)
|
|
106
|
+
} else {
|
|
107
|
+
// Tab bar + home-indicator filler.
|
|
108
|
+
VStack(spacing: 0) {
|
|
109
|
+
BottomTabBar(
|
|
110
|
+
items: items,
|
|
111
|
+
floatingButton: floatingButton,
|
|
112
|
+
selectedIndex: selectedIndex,
|
|
113
|
+
onTabSelected: selectTab
|
|
114
|
+
)
|
|
115
|
+
theme.colors.background.surface
|
|
116
|
+
.frame(height: bottomInset + Spacing.S)
|
|
117
|
+
.ignoresSafeArea(edges: .horizontal)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
122
|
+
.ignoresSafeArea(edges: .bottom)
|
|
123
|
+
.preference(key: IsBottomTabRootKey.self, value: true)
|
|
124
|
+
.onAppear {
|
|
125
|
+
hostNavigation?.setOptions {
|
|
126
|
+
$0.headerType = .none
|
|
127
|
+
$0.useAvoidKeyboard = false
|
|
128
|
+
}
|
|
129
|
+
updateTabBack()
|
|
130
|
+
}
|
|
131
|
+
.onChange(of: selectedIndex) { _ in updateTabBack() }
|
|
132
|
+
.onDisappear {
|
|
133
|
+
// both live outside this view
|
|
134
|
+
navigator.tabBackHandler = nil
|
|
135
|
+
HostBackGestureBridge.shared.shouldUseFloatingArrowBack = false
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private func selectTab(_ index: Int) {
|
|
140
|
+
let switchTab = { if index != selectedIndex { selectedIndex = index } }
|
|
141
|
+
if let onTabPress { onTabPress(index, switchTab) } else { switchTab() }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/// Back off the initial tab returns to it; the host shows its floating arrow there (RN parity).
|
|
145
|
+
private func updateTabBack() {
|
|
146
|
+
let isFirstTab = selectedIndex == startIndex || !backToInitialTab
|
|
147
|
+
HostBackGestureBridge.shared.shouldUseFloatingArrowBack = !isFirstTab
|
|
148
|
+
navigator.tabBackHandler = isFirstTab ? nil : (hostNavigation?.id ?? -1, { selectedIndex = startIndex })
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private func tabScreen(for index: Int) -> some View {
|
|
152
|
+
let item = items[index]
|
|
153
|
+
return TabScreenHost(
|
|
154
|
+
navigation: tabNavigations.navigation(for: index, options: item.options),
|
|
155
|
+
screen: item.screen,
|
|
156
|
+
goBack: { navigator.onBackSafe() }
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/// Keeps each tab's `Navigation`, so options a tab set at runtime survive switching away.
|
|
162
|
+
private final class TabNavigations: ObservableObject {
|
|
163
|
+
private var navigations: [Int: Navigation] = [:]
|
|
164
|
+
|
|
165
|
+
func navigation(for index: Int, options: NavigationOptions?) -> Navigation {
|
|
166
|
+
if let navigation = navigations[index] { return navigation }
|
|
167
|
+
let navigation = Navigation(bottomTabIndex: index, options: options)
|
|
168
|
+
navigations[index] = navigation
|
|
169
|
+
return navigation
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private struct TabScreenHost: View {
|
|
174
|
+
@ObservedObject var navigation: Navigation
|
|
175
|
+
let screen: () -> AnyView
|
|
176
|
+
let goBack: () -> Void
|
|
177
|
+
|
|
178
|
+
var body: some View {
|
|
179
|
+
OptionsStackScreen(
|
|
180
|
+
options: navigation.options,
|
|
181
|
+
screenName: navigation.options.screenName ?? "",
|
|
182
|
+
goBack: goBack,
|
|
183
|
+
content: screen
|
|
184
|
+
)
|
|
185
|
+
.environmentObject(navigation)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// MARK: - Tab host
|
|
190
|
+
|
|
191
|
+
/// Compose's tab NavHost with `saveState` / `restoreState`. A tab gets its own UIHostingController
|
|
192
|
+
/// on first selection and keeps it, so its state and scroll survive a switch; while hidden it is
|
|
193
|
+
/// out of the window, so it gets onDisappear / onAppear (and re-runs screen tracking) like a NavHost
|
|
194
|
+
/// destination or a UITabBarController tab. Same scheme as `StackController`.
|
|
195
|
+
private struct TabHost: UIViewControllerRepresentable {
|
|
196
|
+
let selectedIndex: Int
|
|
197
|
+
let count: Int
|
|
198
|
+
let content: (Int) -> AnyView
|
|
199
|
+
|
|
200
|
+
func makeUIViewController(context: Context) -> TabController {
|
|
201
|
+
TabController()
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
func updateUIViewController(_ controller: TabController, context: Context) {
|
|
205
|
+
controller.update(selectedIndex: selectedIndex, count: count, environment: context.environment, content: content)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private final class TabController: UIViewController {
|
|
210
|
+
private final class Tab {
|
|
211
|
+
let motion = TabMotion()
|
|
212
|
+
var host: UIHostingController<AnyView>!
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
private static let switchDuration: TimeInterval = 0.2
|
|
216
|
+
|
|
217
|
+
private var tabs: [Int: Tab] = [:]
|
|
218
|
+
private var selectedIndex: Int?
|
|
219
|
+
private var environment = EnvironmentValues()
|
|
220
|
+
private var content: (Int) -> AnyView = { _ in AnyView(EmptyView()) }
|
|
221
|
+
|
|
222
|
+
override func viewDidLoad() {
|
|
223
|
+
super.viewDidLoad()
|
|
224
|
+
view.backgroundColor = .clear
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
override func viewDidLayoutSubviews() {
|
|
228
|
+
super.viewDidLayoutSubviews()
|
|
229
|
+
for subview in view.subviews { subview.frame = view.bounds }
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
func update(
|
|
233
|
+
selectedIndex index: Int,
|
|
234
|
+
count: Int,
|
|
235
|
+
environment: EnvironmentValues,
|
|
236
|
+
content: @escaping (Int) -> AnyView
|
|
237
|
+
) {
|
|
238
|
+
self.environment = environment
|
|
239
|
+
self.content = content
|
|
240
|
+
for (stale, removed) in tabs where stale >= count {
|
|
241
|
+
tabs[stale] = nil
|
|
242
|
+
tearDown(removed)
|
|
243
|
+
}
|
|
244
|
+
if let selectedIndex, tabs[selectedIndex] == nil { self.selectedIndex = nil }
|
|
245
|
+
// hidden tabs too, so a tab coming back already shows the latest items and environment
|
|
246
|
+
for (tabIndex, existing) in tabs { existing.host.rootView = rootView(tabIndex, existing) }
|
|
247
|
+
guard (0..<count).contains(index), index != selectedIndex else { return }
|
|
248
|
+
|
|
249
|
+
let previous = selectedIndex.flatMap { tabs[$0] }
|
|
250
|
+
selectedIndex = index
|
|
251
|
+
let selected = tabs[index] ?? makeTab(index)
|
|
252
|
+
attach(selected)
|
|
253
|
+
|
|
254
|
+
guard let previous, view.window != nil else {
|
|
255
|
+
selected.motion.visible = true
|
|
256
|
+
previous.map(leave)
|
|
257
|
+
return
|
|
258
|
+
}
|
|
259
|
+
previous.host.view.isUserInteractionEnabled = false
|
|
260
|
+
// a frame at the start values first, so a tab created just now fades in too
|
|
261
|
+
DispatchQueue.main.async {
|
|
262
|
+
selected.motion.visible = true
|
|
263
|
+
previous.motion.visible = false
|
|
264
|
+
}
|
|
265
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + Self.switchDuration) { [weak self] in
|
|
266
|
+
self?.leave(previous)
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
private func makeTab(_ index: Int) -> Tab {
|
|
271
|
+
let tab = Tab()
|
|
272
|
+
let host = UIHostingController(rootView: rootView(index, tab))
|
|
273
|
+
host.view.backgroundColor = .clear
|
|
274
|
+
addChild(host)
|
|
275
|
+
host.didMove(toParent: self)
|
|
276
|
+
tab.host = host
|
|
277
|
+
tabs[index] = tab
|
|
278
|
+
return tab
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
private func rootView(_ index: Int, _ tab: Tab) -> AnyView {
|
|
282
|
+
AnyView(TabCard(motion: tab.motion, content: content(index)).environment(\.self, environment))
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
private func attach(_ tab: Tab) {
|
|
286
|
+
let hostView: UIView = tab.host.view
|
|
287
|
+
hostView.isUserInteractionEnabled = true
|
|
288
|
+
hostView.frame = view.bounds
|
|
289
|
+
if hostView.superview === view {
|
|
290
|
+
view.bringSubviewToFront(hostView)
|
|
291
|
+
} else {
|
|
292
|
+
view.addSubview(hostView)
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/// Off the window once hidden; a tab selected again in the meantime stays.
|
|
297
|
+
private func leave(_ tab: Tab) {
|
|
298
|
+
guard selectedIndex.flatMap({ tabs[$0] }) !== tab else { return }
|
|
299
|
+
tab.host.view.removeFromSuperview()
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
private func tearDown(_ tab: Tab) {
|
|
303
|
+
tab.host.willMove(toParent: nil)
|
|
304
|
+
tab.host.view.removeFromSuperview()
|
|
305
|
+
tab.host.removeFromParent()
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
private final class TabMotion: ObservableObject {
|
|
310
|
+
@Published var visible = false
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/// The tab switch: 0.97 scale + fade over 200ms, both ways (Compose's tab enter / exit transitions).
|
|
314
|
+
private struct TabCard: View {
|
|
315
|
+
@ObservedObject var motion: TabMotion
|
|
316
|
+
let content: AnyView
|
|
317
|
+
|
|
318
|
+
var body: some View {
|
|
319
|
+
content
|
|
320
|
+
.opacity(motion.visible ? 1 : 0)
|
|
321
|
+
.scaleEffect(motion.visible ? 1 : 0.97)
|
|
322
|
+
.animation(.easeInOut(duration: 0.2), value: motion.visible)
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/// Bottom safe-area inset (home indicator).
|
|
327
|
+
private func safeAreaBottomInset() -> CGFloat {
|
|
328
|
+
let bottomInset = UIApplication.shared.connectedScenes
|
|
329
|
+
.compactMap { ($0 as? UIWindowScene)?.keyWindow?.safeAreaInsets.bottom }
|
|
330
|
+
.first ?? 0
|
|
331
|
+
return min(bottomInset, 21)
|
|
332
|
+
}
|