@momo-kits/native-kits 0.300.3 → 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/build.gradle.kts +13 -0
- package/compose/build.gradle.kts +197 -0
- package/compose/build.gradle.kts.backup +197 -0
- package/compose/compose.podspec +47 -0
- package/compose/src/androidMain/kotlin/vn/momo/kits/const/HostFont.android.kt +20 -0
- package/compose/src/androidMain/kotlin/vn/momo/kits/navigation/ScrollToTop.android.kt +6 -0
- package/compose/src/androidMain/kotlin/vn/momo/kits/platform/BackGesture.android.kt +12 -0
- package/compose/src/androidMain/kotlin/vn/momo/kits/platform/OsFontScale.android.kt +7 -0
- package/compose/src/androidMain/kotlin/vn/momo/kits/platform/Platform.android.kt +153 -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 +110 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/application/DesignSystemKits.kt +62 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/application/LiteScreen.kt +1071 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/application/Localize.kt +269 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/application/WidgetContainer.kt +65 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Avatar.kt +157 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Badge.kt +81 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/BadgeDot.kt +32 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/BadgeRibbon.kt +338 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/BaselineView.kt +175 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Button.kt +357 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Carousel.kt +115 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/CheckBox.kt +89 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Chip.kt +128 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Collapse.kt +209 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/CupertinoOverscroll.kt +542 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Divider.kt +49 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Icon.kt +86 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/IconButton.kt +125 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Image.kt +231 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Information.kt +189 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Input.kt +478 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputDropDown.kt +114 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputMoney.kt +265 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputOTP.kt +235 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputPhoneNumber.kt +223 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputSearch.kt +265 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputTextArea.kt +185 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/LazyColumnWithBouncing.kt +343 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Loader.kt +108 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/PaginationDot.kt +56 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/PaginationNumber.kt +41 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/PaginationScroll.kt +84 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/PaginationWhiteDot.kt +40 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/PopupNotify.kt +361 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/PopupPromotion.kt +108 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/ProgressInfo.kt +308 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Radio.kt +59 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Rating.kt +87 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/ScaleSizeScope.kt +21 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Skeleton.kt +90 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Slider.kt +330 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Stepper.kt +220 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Steps.kt +473 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/SuggestAction.kt +122 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Swipe.kt +207 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Switch.kt +92 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/TabView.kt +560 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Tag.kt +86 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Text.kt +125 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Title.kt +207 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Tooltip.kt +501 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/TrustBanner.kt +176 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Uploader.kt +182 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/DateTimePicker.kt +193 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/DateTimePickerTypes.kt +29 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/DateTimePickerUtils.kt +236 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/WheelPicker.kt +180 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/const/Colors.kt +325 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/const/HostFont.kt +17 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/const/Radius.kt +12 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/const/Spacing.kt +16 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/const/Theme.kt +194 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/const/Typography.kt +287 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/layout/Card.kt +54 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/layout/Item.kt +50 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/layout/ScreenLayout.kt +306 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/layout/Section.kt +41 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/AutomationId.kt +52 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/Clickable.kt +66 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/Conditional.kt +11 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/DeprecatedModifier.kt +104 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/Shadow.kt +48 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/Size.kt +51 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/BottomSheet.kt +338 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/HostBackGestureBridge.kt +58 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/ModalScreen.kt +137 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigation.kt +116 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/NavigationContainer.kt +215 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigator.kt +404 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/ScrollToTop.kt +8 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/StackScreen.kt +732 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/StackTransition.kt +148 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/SwipeBackHaptic.kt +40 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTab.kt +233 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTabBar.kt +217 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/CurvedContainer.kt +86 -0
- 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/FloatingButton.kt +169 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/Header.kt +455 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderBackground.kt +103 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderJourney.kt +106 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderLocation.kt +74 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderRight.kt +413 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderTitle.kt +39 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderUser.kt +363 -0
- 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 +133 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/ToolkitRequests.kt +41 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/tracking/ScreenTracker.kt +135 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/platform/BackGesture.kt +12 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/platform/ComposeLottieAnimation.kt +91 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/platform/OsFontScale.kt +9 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/platform/Platform.kt +74 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/platform/ReservedRegions.kt +20 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/utils/Icons.kt +1329 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/utils/Resources.kt +55 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/utils/Tracking.kt +15 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/utils/Utils.kt +118 -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 +62 -0
- package/compose/src/iosMain/kotlin/vn/momo/kits/const/HostFont.ios.kt +42 -0
- package/compose/src/iosMain/kotlin/vn/momo/kits/navigation/HostBackGesture.ios.kt +45 -0
- package/compose/src/iosMain/kotlin/vn/momo/kits/navigation/ScrollToTop.ios.kt +36 -0
- package/compose/src/iosMain/kotlin/vn/momo/kits/platform/BackGesture.ios.kt +38 -0
- package/compose/src/iosMain/kotlin/vn/momo/kits/platform/OsFontScale.ios.kt +66 -0
- package/compose/src/iosMain/kotlin/vn/momo/kits/platform/Platform.ios.kt +217 -0
- package/compose/src/iosMain/kotlin/vn/momo/kits/platform/ReservedRegions.ios.kt +138 -0
- package/gradle/libs.versions.toml +68 -0
- package/gradle/wrapper/gradle-wrapper.jar +0 -0
- package/gradle/wrapper/gradle-wrapper.properties +8 -0
- package/gradle.properties +26 -0
- package/gradlew +252 -0
- package/gradlew.bat +94 -0
- 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/README.md +120 -0
- 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/settings.gradle.kts +64 -0
- 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
|
@@ -0,0 +1,930 @@
|
|
|
1
|
+
//
|
|
2
|
+
// StackScreen.swift
|
|
3
|
+
// MoMoUIKits
|
|
4
|
+
//
|
|
5
|
+
// SwiftUI mirror of Compose's `navigation/StackScreen.kt`: header layer, scrollable content
|
|
6
|
+
// (`StackScreenContent`), animated search, floating button, footer (`NavigationFooter`) and the
|
|
7
|
+
// iPhone Duo side bar.
|
|
8
|
+
//
|
|
9
|
+
|
|
10
|
+
import SwiftUI
|
|
11
|
+
|
|
12
|
+
public struct StackScreen<Content: View>: View {
|
|
13
|
+
// MARK: - Properties
|
|
14
|
+
|
|
15
|
+
var backgroundColor: Color?
|
|
16
|
+
// AI-GENERATED START: custom screen background view (Compose backgroundComponent)
|
|
17
|
+
var background: (() -> AnyView)?
|
|
18
|
+
// AI-GENERATED END: custom screen background view (Compose backgroundComponent)
|
|
19
|
+
var headerTransparent: Bool = false
|
|
20
|
+
var fullScreenContent: Bool = false
|
|
21
|
+
var tintColor: Color? = Colors.black17
|
|
22
|
+
var isBack: Bool
|
|
23
|
+
var headerType: HeaderType
|
|
24
|
+
var useAnimated: Bool = false
|
|
25
|
+
var verticalAlignment: VerticalAlignment
|
|
26
|
+
var horizontalAlignment: HorizontalAlignment
|
|
27
|
+
var title: String
|
|
28
|
+
var headerTitle: HeaderTitle?
|
|
29
|
+
var titlePosition: TitlePosition
|
|
30
|
+
var goBack: (() -> Void)?
|
|
31
|
+
var scrollable: Bool
|
|
32
|
+
var scrollToTopCallback: (() -> Void)?
|
|
33
|
+
var onContentLayout: ((CGRect) -> Void)?
|
|
34
|
+
var useAvoidKeyboard: Bool
|
|
35
|
+
var keyboardShouldPersistTaps: Bool
|
|
36
|
+
var footer: (() -> AnyView)?
|
|
37
|
+
var footerTransparent: Bool
|
|
38
|
+
var footerUseMargin: Bool
|
|
39
|
+
var headerRight: (() -> any View)? = { HeaderRight() }
|
|
40
|
+
var animatedHeader: AnimatedHeader? = nil
|
|
41
|
+
var layoutOffset: CGFloat
|
|
42
|
+
var inputSearchProps: InputSearchProps?
|
|
43
|
+
var fabProps: FabProps?
|
|
44
|
+
var screenName: String = ""
|
|
45
|
+
var content: () -> Content
|
|
46
|
+
|
|
47
|
+
public init(
|
|
48
|
+
backgroundColor: Color? = nil,
|
|
49
|
+
// AI-GENERATED START: custom screen background view (Compose backgroundComponent)
|
|
50
|
+
background: (() -> AnyView)? = nil,
|
|
51
|
+
// AI-GENERATED END: custom screen background view (Compose backgroundComponent)
|
|
52
|
+
headerTransparent: Bool = false,
|
|
53
|
+
fullScreenContent: Bool = false,
|
|
54
|
+
tintColor: Color = Colors.black17,
|
|
55
|
+
isBack: Bool = true,
|
|
56
|
+
headerType: HeaderType = .default,
|
|
57
|
+
useAnimated: Bool = false,
|
|
58
|
+
verticalAlignment: VerticalAlignment = .top,
|
|
59
|
+
horizontalAlignment: HorizontalAlignment = .leading,
|
|
60
|
+
title: String = "Stack",
|
|
61
|
+
headerTitle: HeaderTitle? = nil,
|
|
62
|
+
titlePosition: TitlePosition = .left,
|
|
63
|
+
goBack: (() -> Void)? = nil,
|
|
64
|
+
scrollable: Bool = true,
|
|
65
|
+
scrollToTopCallback: (() -> Void)? = nil,
|
|
66
|
+
onContentLayout: ((CGRect) -> Void)? = nil,
|
|
67
|
+
useAvoidKeyboard: Bool = true,
|
|
68
|
+
keyboardShouldPersistTaps: Bool = false,
|
|
69
|
+
footer: (() -> AnyView)? = nil,
|
|
70
|
+
footerTransparent: Bool = false,
|
|
71
|
+
footerUseMargin: Bool = true,
|
|
72
|
+
headerRight: (() -> any View)? = { HeaderRight() },
|
|
73
|
+
animatedHeader: AnimatedHeader? = nil,
|
|
74
|
+
layoutOffset: CGFloat = 56,
|
|
75
|
+
inputSearchProps: InputSearchProps? = nil,
|
|
76
|
+
fabProps: FabProps? = nil,
|
|
77
|
+
screenName: String = "",
|
|
78
|
+
@ViewBuilder content: @escaping () -> Content
|
|
79
|
+
) {
|
|
80
|
+
self.backgroundColor = backgroundColor
|
|
81
|
+
// AI-GENERATED START: custom screen background view (Compose backgroundComponent)
|
|
82
|
+
self.background = background
|
|
83
|
+
// AI-GENERATED END: custom screen background view (Compose backgroundComponent)
|
|
84
|
+
self.headerTransparent = headerTransparent
|
|
85
|
+
self.fullScreenContent = fullScreenContent
|
|
86
|
+
self.tintColor = tintColor
|
|
87
|
+
self.isBack = isBack
|
|
88
|
+
self.headerType = headerType
|
|
89
|
+
self.useAnimated = useAnimated
|
|
90
|
+
self.verticalAlignment = verticalAlignment
|
|
91
|
+
self.horizontalAlignment = horizontalAlignment
|
|
92
|
+
self.title = title
|
|
93
|
+
self.headerTitle = headerTitle
|
|
94
|
+
self.titlePosition = titlePosition
|
|
95
|
+
self.goBack = goBack
|
|
96
|
+
self.scrollable = scrollable
|
|
97
|
+
self.scrollToTopCallback = scrollToTopCallback
|
|
98
|
+
self.onContentLayout = onContentLayout
|
|
99
|
+
self.useAvoidKeyboard = useAvoidKeyboard
|
|
100
|
+
self.keyboardShouldPersistTaps = keyboardShouldPersistTaps
|
|
101
|
+
self.footer = footer
|
|
102
|
+
self.footerTransparent = footerTransparent
|
|
103
|
+
self.footerUseMargin = footerUseMargin
|
|
104
|
+
self.headerRight = headerRight
|
|
105
|
+
self.animatedHeader = animatedHeader
|
|
106
|
+
self.layoutOffset = layoutOffset
|
|
107
|
+
self.inputSearchProps = inputSearchProps
|
|
108
|
+
self.fabProps = fabProps
|
|
109
|
+
self.screenName = screenName
|
|
110
|
+
self.content = content
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
@State private var scrollOffset: CGFloat = 0
|
|
114
|
+
@State private var keyboardHeight: CGFloat = 0
|
|
115
|
+
@State private var headerRightWidth: CGFloat = 0
|
|
116
|
+
@State private var isBottomTabRoot: Bool = false
|
|
117
|
+
@State private var headerHide: CGFloat = 0
|
|
118
|
+
@State private var lastScrollOffset: CGFloat = 0
|
|
119
|
+
@State private var footerHeight: CGFloat = 0
|
|
120
|
+
|
|
121
|
+
@Environment(\.applicationEnvironment) private var appEnv
|
|
122
|
+
@Environment(\.screenLayout) private var layout
|
|
123
|
+
@Environment(\.duoTabBarInsets) private var duoTabBarInsets
|
|
124
|
+
@Environment(\.duoBarCompression) private var compression
|
|
125
|
+
@Environment(\.appTheme) private var theme
|
|
126
|
+
@OptionalEnvironmentObject private var navigation: Navigation?
|
|
127
|
+
|
|
128
|
+
@State private var tracking: Task<Void, Never>?
|
|
129
|
+
|
|
130
|
+
/// Compose's `headerOpacity()`: 0 over a banner or transparent header, 1 once the bar is opaque.
|
|
131
|
+
private var opacity: CGFloat {
|
|
132
|
+
headerOpacity(
|
|
133
|
+
scrollOffset: scrollOffset,
|
|
134
|
+
isAnimated: animatedHeader != nil,
|
|
135
|
+
layoutOffset: layoutOffset,
|
|
136
|
+
transparent: headerTransparent
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private var headerColor: HeaderColor {
|
|
141
|
+
getHeaderColor(
|
|
142
|
+
isAnimated: animatedHeader != nil,
|
|
143
|
+
isSurface: animatedHeader?.isSurface ?? true,
|
|
144
|
+
opacity: opacity,
|
|
145
|
+
tintColor: tintColor
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// MARK: - Search header (Compose's getInputSearchType / SearchAnimated)
|
|
150
|
+
|
|
151
|
+
private enum InputSearchType { case none, header, animated }
|
|
152
|
+
|
|
153
|
+
private var inputSearchType: InputSearchType {
|
|
154
|
+
guard inputSearchProps != nil, headerType != .none else { return .none }
|
|
155
|
+
return useAnimated ? .animated : .header
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private var toolkit: (data: HeaderRightData?, tintColor: Color?)? { toolkitHeaderRight(headerRight) }
|
|
159
|
+
|
|
160
|
+
private var showsSideBar: Bool {
|
|
161
|
+
layout.sideBar != nil && headerType != .none && !isBottomTabRoot && (goBack != nil || toolkit != nil)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/// `HeaderSideBar` reports its own height; an empty column reports 0, or the rail keeps the previous tab's.
|
|
165
|
+
private func reportEmptySideBar() {
|
|
166
|
+
if layout.sideBar != nil && !showsSideBar { compression.setToolbarHeight(0) }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/// iPhone Duo: content scrolls under a glass header instead of starting below it.
|
|
170
|
+
private var useDuoGlass: Bool {
|
|
171
|
+
layout.sideBar != nil && headerType != .none && animatedHeader == nil
|
|
172
|
+
&& inputSearchProps == nil && !headerTransparent && !fullScreenContent
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/// iPhone Duo: the header leaves with the content and slides back in on a reverse scroll
|
|
176
|
+
/// (RN DuoQuickReturnHeader). The side bar is drawn apart from the header, so it stays put.
|
|
177
|
+
private var useQuickReturn: Bool {
|
|
178
|
+
layout.sideBar != nil && scrollable && (useDuoGlass || animatedHeader != nil)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private func updateHeaderHide(_ offset: CGFloat) {
|
|
182
|
+
let delta = offset - lastScrollOffset
|
|
183
|
+
lastScrollOffset = offset
|
|
184
|
+
guard useQuickReturn, offset > 0 else {
|
|
185
|
+
if headerHide != 0 { headerHide = 0 }
|
|
186
|
+
return
|
|
187
|
+
}
|
|
188
|
+
headerHide = min(max(headerHide + delta, 0), layout.headerHeight)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/// A half-shown header settles fully in or out once the scroll comes to rest.
|
|
192
|
+
private func settleHeader() {
|
|
193
|
+
let height = layout.headerHeight
|
|
194
|
+
// within the first header height the header still sits on the content
|
|
195
|
+
guard useQuickReturn, scrollOffset >= height, headerHide > 0, headerHide < height else { return }
|
|
196
|
+
withAnimation(.easeOut(duration: 0.2)) {
|
|
197
|
+
headerHide = headerHide > height / 2 ? height : 0
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/// Compose snaps the animated search fully in or out when the scroll stops.
|
|
202
|
+
private var searchSnapDistance: CGFloat? { inputSearchType == .animated ? HEADER_HEIGHT : nil }
|
|
203
|
+
|
|
204
|
+
private var keyboardSize: CGFloat { useAvoidKeyboard ? max(keyboardHeight, 0) : 0 }
|
|
205
|
+
|
|
206
|
+
/// What the footer, tab bar or home indicator takes from the bottom of the window, gap included.
|
|
207
|
+
private var bottomBarsHeight: CGFloat {
|
|
208
|
+
let bars = footer != nil ? footerHeight + NavigationFooter.safeAreaBottom : NavigationFooter.navigationBarInset
|
|
209
|
+
return max(bars + Spacing.XL, duoTabBarInsets.sideBarReserve)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/// Top of the footer (or the navigation bar) from the window bottom; snack bars sit above it.
|
|
213
|
+
private var bottomObstruction: CGFloat {
|
|
214
|
+
guard footer != nil else { return keyboardSize + NavigationFooter.navigationBarInset }
|
|
215
|
+
return footerHeight + NavigationFooter.safeAreaBottom - NavigationFooter.verticalOffset(keyboardSize: keyboardSize)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
private func reportBottomObstruction() {
|
|
219
|
+
// bottom tab children leave overplays to the screen hosting the tabs, as in Compose
|
|
220
|
+
guard let navigation, navigation.bottomTabIndex == -1 else { return }
|
|
221
|
+
navigation.bottomObstruction = bottomObstruction
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/// Keyboard frames are in screen coordinates; UIScreen stays on the outer display on iPhone Duo.
|
|
225
|
+
private var keyboardReferenceHeight: CGFloat {
|
|
226
|
+
layout.isIPhoneDuo ? layout.height : UIScreen.main.bounds.height
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/// Room for the animated search below the header at scroll 0; shrinks as the bar slides up.
|
|
230
|
+
private var animatedSearchInset: CGFloat {
|
|
231
|
+
guard inputSearchType == .animated else { return 0 }
|
|
232
|
+
let scroll = max(scrollOffset, 0)
|
|
233
|
+
return min(max(HEADER_HEIGHT - scroll, 0), HEADER_HEIGHT)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/// Title fades out as the animated search bar rises into the header.
|
|
237
|
+
private var titleOpacity: CGFloat {
|
|
238
|
+
guard inputSearchType == .animated else { return 1 }
|
|
239
|
+
let scroll = max(scrollOffset, 0)
|
|
240
|
+
return 1 - min(max(scroll / HEADER_HEIGHT, 0), 1)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// MARK: - Body
|
|
244
|
+
|
|
245
|
+
public var body: some View {
|
|
246
|
+
GeometryReader { _ in
|
|
247
|
+
ZStack(alignment: .top) {
|
|
248
|
+
// AI-GENERATED START: custom screen background view (Compose backgroundComponent)
|
|
249
|
+
Group {
|
|
250
|
+
if let background {
|
|
251
|
+
background()
|
|
252
|
+
} else {
|
|
253
|
+
backgroundColor ?? theme.colors.background.default
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
.edgesIgnoringSafeArea(.all)
|
|
257
|
+
// AI-GENERATED END: custom screen background view (Compose backgroundComponent)
|
|
258
|
+
|
|
259
|
+
VStack(spacing: 0) {
|
|
260
|
+
ZStack(alignment: fabProps?.position == .center ? .bottom : .bottomTrailing) {
|
|
261
|
+
VStack(alignment: horizontalAlignment, spacing: 0) {
|
|
262
|
+
headerAndContent
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
FloatingContent(
|
|
266
|
+
fabProps: fabProps,
|
|
267
|
+
keyboardSize: keyboardSize,
|
|
268
|
+
hasFooter: footer != nil,
|
|
269
|
+
scrollOffset: scrollOffset
|
|
270
|
+
)
|
|
271
|
+
.padding(.bottom, footer == nil ? duoTabBarInsets.overlay : 0)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
NavigationFooter(
|
|
275
|
+
footerComponent: footer,
|
|
276
|
+
keyboardSize: keyboardSize,
|
|
277
|
+
transparent: footerTransparent,
|
|
278
|
+
useMargin: footerUseMargin
|
|
279
|
+
)
|
|
280
|
+
.padding(.bottom, duoTabBarInsets.overlay)
|
|
281
|
+
.background(
|
|
282
|
+
GeometryReader { proxy in
|
|
283
|
+
Color.clear
|
|
284
|
+
.onAppear { footerHeight = proxy.size.height }
|
|
285
|
+
.onChange(of: proxy.size.height) { footerHeight = $0 }
|
|
286
|
+
}
|
|
287
|
+
)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if inputSearchType == .animated, let props = inputSearchProps {
|
|
291
|
+
SearchAnimatedHeader(
|
|
292
|
+
props: props,
|
|
293
|
+
scrollOffset: scrollOffset,
|
|
294
|
+
hiddenBack: goBack == nil,
|
|
295
|
+
hasHeaderRight: headerRight != nil,
|
|
296
|
+
headerRightWidth: headerRightWidth,
|
|
297
|
+
useSideBar: layout.sideBar != nil,
|
|
298
|
+
hasToolkit: toolkit != nil
|
|
299
|
+
)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if showsSideBar, let sideBar = layout.sideBar {
|
|
303
|
+
HeaderSideBar(
|
|
304
|
+
sideBar: sideBar,
|
|
305
|
+
goBack: goBack,
|
|
306
|
+
toolkit: toolkit,
|
|
307
|
+
headerColor: headerColor,
|
|
308
|
+
bottomInset: bottomBarsHeight
|
|
309
|
+
)
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
.ignoresSafeArea(.keyboard, edges: .bottom)
|
|
313
|
+
.onChange(of: scrollOffset) { updateHeaderHide($0) }
|
|
314
|
+
.onAppear { reportEmptySideBar() }
|
|
315
|
+
.onChange(of: showsSideBar) { _ in reportEmptySideBar() }
|
|
316
|
+
.onPreferenceChange(HeaderRightWidthKey.self) { width in
|
|
317
|
+
if headerRightWidth != width { headerRightWidth = width }
|
|
318
|
+
}
|
|
319
|
+
.onPreferenceChange(IsBottomTabRootKey.self) { isRoot in
|
|
320
|
+
if isBottomTabRoot != isRoot { isBottomTabRoot = isRoot }
|
|
321
|
+
}
|
|
322
|
+
.onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillChangeFrameNotification)) { notification in
|
|
323
|
+
guard useAvoidKeyboard,
|
|
324
|
+
let endFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return }
|
|
325
|
+
withAnimation(.easeOut(duration: 0.25)) {
|
|
326
|
+
keyboardHeight = keyboardReferenceHeight - endFrame.origin.y
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
// Compose's `hideKeyboardOnTap()`: simultaneous, so taps still reach buttons and fields.
|
|
330
|
+
.modifier(HideKeyboardOnTap(enabled: keyboardShouldPersistTaps))
|
|
331
|
+
}
|
|
332
|
+
.onAppear(perform: reportBottomObstruction)
|
|
333
|
+
.onChange(of: bottomObstruction) { _ in reportBottomObstruction() }
|
|
334
|
+
.onAppear(perform: startTracking)
|
|
335
|
+
.onChange(of: screenName) { _ in startTracking() }
|
|
336
|
+
.onDisappear { tracking?.cancel() }
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/// Compose tracks from a `LaunchedEffect` with `remember`ed state, so every visit — first show, a
|
|
340
|
+
/// pop back, a tab switch — starts over with fresh state. A covered hosting controller keeps its
|
|
341
|
+
/// `@State` and doesn't re-run `.task`, so the run hangs off appear / disappear instead.
|
|
342
|
+
private func startTracking() {
|
|
343
|
+
tracking?.cancel()
|
|
344
|
+
let state = ScreenTrackingState()
|
|
345
|
+
let action = ScreenTracker.getLastScreenName() != nil ? "push" : "back"
|
|
346
|
+
let maxApi = appEnv.maxApi
|
|
347
|
+
let context = appEnv.applicationContext
|
|
348
|
+
let screenName = screenName
|
|
349
|
+
tracking = Task { @MainActor in
|
|
350
|
+
ScreenTracker.trackScreenNavigated(
|
|
351
|
+
maxApi: maxApi,
|
|
352
|
+
context: context,
|
|
353
|
+
screenName: screenName,
|
|
354
|
+
action: action,
|
|
355
|
+
state: state
|
|
356
|
+
)
|
|
357
|
+
do {
|
|
358
|
+
try await Task.sleep(nanoseconds: 2_000_000_000)
|
|
359
|
+
} catch {
|
|
360
|
+
return
|
|
361
|
+
}
|
|
362
|
+
let loadTime = ScreenTracker.now() - state.time
|
|
363
|
+
ScreenTracker.trackScreenDisplayed(
|
|
364
|
+
maxApi: maxApi,
|
|
365
|
+
context: context,
|
|
366
|
+
screenName: screenName,
|
|
367
|
+
duration: loadTime,
|
|
368
|
+
state: state
|
|
369
|
+
)
|
|
370
|
+
let interactionTime = ScreenTracker.now() - state.time
|
|
371
|
+
ScreenTracker.trackScreenInteracted(
|
|
372
|
+
maxApi: maxApi,
|
|
373
|
+
context: context,
|
|
374
|
+
screenName: screenName,
|
|
375
|
+
duration: interactionTime - loadTime,
|
|
376
|
+
totalDuration: interactionTime,
|
|
377
|
+
state: state
|
|
378
|
+
)
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// MARK: - Header + content layer
|
|
383
|
+
|
|
384
|
+
@ViewBuilder
|
|
385
|
+
private var headerAndContent: some View {
|
|
386
|
+
if let animatedHeader = animatedHeader {
|
|
387
|
+
// The banner is the top of the scroll content and the body overlaps its lower part;
|
|
388
|
+
// the bar fades in over it (Compose's ScreenContent for HeaderType.Animated).
|
|
389
|
+
ZStack(alignment: .top) {
|
|
390
|
+
StackScreenContent(
|
|
391
|
+
scrollable: scrollable,
|
|
392
|
+
scrollOffset: $scrollOffset,
|
|
393
|
+
scrollToTopCallback: scrollToTopCallback,
|
|
394
|
+
animatedSearchInset: animatedSearchInset,
|
|
395
|
+
bottomInset: duoTabBarInsets.overlay,
|
|
396
|
+
snapDistance: searchSnapDistance,
|
|
397
|
+
onScrollIdle: settleHeader,
|
|
398
|
+
content: { animatedHeroBody(animatedHeader) }
|
|
399
|
+
)
|
|
400
|
+
.edgesIgnoringSafeArea(.all)
|
|
401
|
+
|
|
402
|
+
header
|
|
403
|
+
.background(alignment: .top) {
|
|
404
|
+
Group {
|
|
405
|
+
if layout.sideBar != nil {
|
|
406
|
+
// the bar shows once the banner has scrolled under the header
|
|
407
|
+
DuoHeaderBar(scrollOffset: scrollOffset, fadeStart: heroGlassStart(animatedHeader))
|
|
408
|
+
} else {
|
|
409
|
+
HeaderBarBackground(opacity: opacity)
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
.edgesIgnoringSafeArea([.top, .horizontal])
|
|
413
|
+
}
|
|
414
|
+
.offset(y: -headerHide)
|
|
415
|
+
.zIndex(100)
|
|
416
|
+
}
|
|
417
|
+
} else if headerTransparent && fullScreenContent {
|
|
418
|
+
// Compose's HeaderType.Transparent(isFullScreenContent): content starts under a clear bar
|
|
419
|
+
ZStack(alignment: .top) {
|
|
420
|
+
header
|
|
421
|
+
.zIndex(100)
|
|
422
|
+
|
|
423
|
+
StackScreenContent(
|
|
424
|
+
scrollable: scrollable,
|
|
425
|
+
scrollOffset: $scrollOffset,
|
|
426
|
+
scrollToTopCallback: scrollToTopCallback,
|
|
427
|
+
animatedSearchInset: animatedSearchInset,
|
|
428
|
+
bottomInset: duoTabBarInsets.overlay,
|
|
429
|
+
snapDistance: searchSnapDistance,
|
|
430
|
+
onScrollIdle: settleHeader,
|
|
431
|
+
content: { duoInsetContent }
|
|
432
|
+
)
|
|
433
|
+
.edgesIgnoringSafeArea(.all)
|
|
434
|
+
}
|
|
435
|
+
} else if useDuoGlass {
|
|
436
|
+
ZStack(alignment: .top) {
|
|
437
|
+
DuoScrollGradient(
|
|
438
|
+
scrollOffset: scrollOffset,
|
|
439
|
+
headerHeight: layout.headerHeight,
|
|
440
|
+
extended: headerType == .extended,
|
|
441
|
+
followScroll: useQuickReturn
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
StackScreenContent(
|
|
445
|
+
scrollable: scrollable,
|
|
446
|
+
scrollOffset: $scrollOffset,
|
|
447
|
+
scrollToTopCallback: scrollToTopCallback,
|
|
448
|
+
topInset: layout.headerHeight - layout.insets.top,
|
|
449
|
+
bottomInset: duoTabBarInsets.overlay,
|
|
450
|
+
snapDistance: searchSnapDistance,
|
|
451
|
+
onScrollIdle: settleHeader,
|
|
452
|
+
content: content
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
header
|
|
456
|
+
.background(
|
|
457
|
+
DuoHeaderBar(scrollOffset: scrollOffset)
|
|
458
|
+
.edgesIgnoringSafeArea([.top, .horizontal])
|
|
459
|
+
)
|
|
460
|
+
.offset(y: -headerHide)
|
|
461
|
+
.zIndex(100)
|
|
462
|
+
}
|
|
463
|
+
} else {
|
|
464
|
+
header
|
|
465
|
+
// top-aligned: an extended band runs past the bar, under the content
|
|
466
|
+
.background(alignment: .top) {
|
|
467
|
+
ZStack(alignment: .top) {
|
|
468
|
+
if layout.sideBar != nil && inputSearchType != .none && !headerTransparent {
|
|
469
|
+
// RN HeaderExtendHeader with search on the side bar layout: the
|
|
470
|
+
// gradient fades out within the header box, as without the search
|
|
471
|
+
DuoScrollGradient(
|
|
472
|
+
scrollOffset: scrollOffset,
|
|
473
|
+
headerHeight: layout.headerHeight,
|
|
474
|
+
extended: headerType == .extended
|
|
475
|
+
)
|
|
476
|
+
} else {
|
|
477
|
+
NavigationHeaderBackground(
|
|
478
|
+
headerType: headerType,
|
|
479
|
+
scrollState: scrollOffset,
|
|
480
|
+
headerTransparent: headerTransparent,
|
|
481
|
+
fullScreenContent: fullScreenContent,
|
|
482
|
+
backgroundColor: backgroundColor
|
|
483
|
+
)
|
|
484
|
+
}
|
|
485
|
+
HeaderBarBackground(opacity: opacity, transparent: headerTransparent || headerType == .none)
|
|
486
|
+
}
|
|
487
|
+
.edgesIgnoringSafeArea([.top, .horizontal])
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
StackScreenContent(
|
|
491
|
+
scrollable: scrollable,
|
|
492
|
+
scrollOffset: $scrollOffset,
|
|
493
|
+
scrollToTopCallback: scrollToTopCallback,
|
|
494
|
+
animatedSearchInset: animatedSearchInset,
|
|
495
|
+
bottomInset: duoTabBarInsets.overlay,
|
|
496
|
+
snapDistance: searchSnapDistance,
|
|
497
|
+
onScrollIdle: settleHeader,
|
|
498
|
+
content: content
|
|
499
|
+
)
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/// Edge-to-edge branches lay out outside the safe area; iPhone Duo content still keeps
|
|
504
|
+
/// clear of the side strips, as RN's Screen does.
|
|
505
|
+
private var duoInsetContent: some View {
|
|
506
|
+
content()
|
|
507
|
+
.padding(.leading, layout.insetLeft)
|
|
508
|
+
.padding(.trailing, layout.insetRight)
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
private func heroGlassStart(_ animatedHeader: AnimatedHeader) -> CGFloat {
|
|
512
|
+
max(layout.width / animatedHeader.aspectRatio.value - layout.headerHeight, 0)
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/// The banner fills the width at its aspect ratio; the body starts `layoutOffset` below the
|
|
516
|
+
/// header band so it overlaps the banner's lower part.
|
|
517
|
+
@ViewBuilder
|
|
518
|
+
private func animatedHeroBody(_ animatedHeader: AnimatedHeader) -> some View {
|
|
519
|
+
let width = layout.width
|
|
520
|
+
let imageHeight = width / animatedHeader.aspectRatio.value
|
|
521
|
+
let contentTopInset = layout.headerHeight + layoutOffset
|
|
522
|
+
|
|
523
|
+
ZStack(alignment: .top) {
|
|
524
|
+
animatedHeader.composable(scrollOffset)
|
|
525
|
+
.frame(width: width, height: imageHeight)
|
|
526
|
+
.clipped()
|
|
527
|
+
|
|
528
|
+
duoInsetContent
|
|
529
|
+
.padding(.top, contentTopInset)
|
|
530
|
+
}
|
|
531
|
+
.frame(maxWidth: .infinity, alignment: .top)
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
private var header: NavigationHeader {
|
|
535
|
+
NavigationHeader(
|
|
536
|
+
headerType: headerType,
|
|
537
|
+
titlePosition: titlePosition,
|
|
538
|
+
title: title,
|
|
539
|
+
headerTitle: headerTitle,
|
|
540
|
+
headerRight: headerRight,
|
|
541
|
+
goBack: goBack,
|
|
542
|
+
opacity: opacity,
|
|
543
|
+
animatedHeader: animatedHeader,
|
|
544
|
+
scrollState: scrollOffset,
|
|
545
|
+
// the animated search draws its own field over the (fading) title
|
|
546
|
+
inputSearchProps: inputSearchType == .header ? inputSearchProps : nil,
|
|
547
|
+
tintColor: tintColor,
|
|
548
|
+
titleOpacity: titleOpacity
|
|
549
|
+
)
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// MARK: - Bottom-tab root preference
|
|
554
|
+
|
|
555
|
+
/// Set by `BottomTab` so the `StackScreen` hosting it keeps the full width and leaves the side
|
|
556
|
+
/// bar to the tabs (Compose's `Navigation.markAsBottomTabRoot()`).
|
|
557
|
+
public struct IsBottomTabRootKey: PreferenceKey {
|
|
558
|
+
public static var defaultValue: Bool = false
|
|
559
|
+
public static func reduce(value: inout Bool, nextValue: () -> Bool) {
|
|
560
|
+
value = value || nextValue()
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// MARK: - Screen content (scrollable body)
|
|
565
|
+
|
|
566
|
+
/// Compose's `MainContent` / `ScreenContent`: the body, tracking the scroll offset when scrollable.
|
|
567
|
+
private struct StackScreenContent<Content: View>: View {
|
|
568
|
+
let scrollable: Bool
|
|
569
|
+
@Binding var scrollOffset: CGFloat
|
|
570
|
+
/// Compose's `ScrollData.scrollToTopCallback`, fired with the status-bar tap scroll-to-top.
|
|
571
|
+
let scrollToTopCallback: (() -> Void)?
|
|
572
|
+
/// Room for the animated search below the header (Compose's MainContent spacer).
|
|
573
|
+
var animatedSearchInset: CGFloat = 0
|
|
574
|
+
/// Room for a header the content scrolls under (iPhone Duo glass header).
|
|
575
|
+
var topInset: CGFloat = 0
|
|
576
|
+
/// Room for a bar that overlays the content (iPhone Duo floating tab bar).
|
|
577
|
+
var bottomInset: CGFloat = 0
|
|
578
|
+
/// Compose's animated-search snap: a scroll that comes to rest part way through this distance
|
|
579
|
+
/// finishes the move, up or back down.
|
|
580
|
+
var snapDistance: CGFloat? = nil
|
|
581
|
+
var onScrollIdle: (() -> Void)? = nil
|
|
582
|
+
let content: () -> Content
|
|
583
|
+
|
|
584
|
+
private let topAnchorID = "stack_screen_top_anchor"
|
|
585
|
+
private let scrollSpace = "stack_screen_scroll_space"
|
|
586
|
+
|
|
587
|
+
@State private var scrollView = WeakScrollView()
|
|
588
|
+
|
|
589
|
+
private func scrollDidRest() {
|
|
590
|
+
onScrollIdle?()
|
|
591
|
+
guard let snapDistance, scrollOffset > 0, scrollOffset < snapDistance,
|
|
592
|
+
let scrollView = scrollView.value else { return }
|
|
593
|
+
// UIKit, not `ScrollViewProxy.scrollTo`: SwiftUI keeps re-applying a scrollTo target while the
|
|
594
|
+
// content above it resizes, which this content does as the search inset shrinks with the scroll
|
|
595
|
+
let target = scrollOffset < snapDistance / 2 ? 0 : snapDistance
|
|
596
|
+
scrollView.setContentOffset(
|
|
597
|
+
CGPoint(x: scrollView.contentOffset.x, y: target - scrollView.adjustedContentInset.top),
|
|
598
|
+
animated: true
|
|
599
|
+
)
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
var body: some View {
|
|
603
|
+
if scrollable {
|
|
604
|
+
ScrollViewReader { proxy in
|
|
605
|
+
ScrollView(.vertical, showsIndicators: false) {
|
|
606
|
+
VStack(spacing: 0) {
|
|
607
|
+
// Top probe + scroll-to-top anchor, measured in the scroll's own space so the
|
|
608
|
+
// offset is a clean 0-based "points scrolled", independent of the insets below.
|
|
609
|
+
Color.clear
|
|
610
|
+
.frame(height: 0)
|
|
611
|
+
.id(topAnchorID)
|
|
612
|
+
.background(GeometryReader { geo in
|
|
613
|
+
Color.clear
|
|
614
|
+
.onAppear {
|
|
615
|
+
scrollOffset = -geo.frame(in: .named(scrollSpace)).minY
|
|
616
|
+
}
|
|
617
|
+
.onChange(of: geo.frame(in: .named(scrollSpace)).minY) { minY in
|
|
618
|
+
scrollOffset = -minY
|
|
619
|
+
}
|
|
620
|
+
})
|
|
621
|
+
.background(ScrollViewProbe { scrollView.value = $0 })
|
|
622
|
+
|
|
623
|
+
if animatedSearchInset + topInset > 0 {
|
|
624
|
+
Spacer().frame(height: animatedSearchInset + topInset)
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
VStack {
|
|
628
|
+
content()
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
if bottomInset > 0 {
|
|
632
|
+
Spacer().frame(height: bottomInset)
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
.coordinateSpace(name: scrollSpace)
|
|
637
|
+
.modifier(ScrollRest(offset: scrollOffset, onRest: scrollDidRest))
|
|
638
|
+
// Status-bar tap (MoMoStatusBarTap swizzle) scrolls to top and notifies the consumer.
|
|
639
|
+
.onReceive(NotificationCenter.default.publisher(for: MoMoStatusBarTap.notificationName)) { _ in
|
|
640
|
+
withAnimation {
|
|
641
|
+
proxy.scrollTo(topAnchorID, anchor: .top)
|
|
642
|
+
}
|
|
643
|
+
scrollToTopCallback?()
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
} else {
|
|
647
|
+
content()
|
|
648
|
+
.padding(.top, topInset)
|
|
649
|
+
.padding(.bottom, bottomInset)
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
private final class WeakScrollView {
|
|
655
|
+
weak var value: UIScrollView?
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/// Finds the UIScrollView behind the enclosing SwiftUI `ScrollView` once placed in its content.
|
|
659
|
+
private struct ScrollViewProbe: UIViewRepresentable {
|
|
660
|
+
let found: (UIScrollView) -> Void
|
|
661
|
+
|
|
662
|
+
func makeUIView(context: Context) -> Probe { Probe(found: found) }
|
|
663
|
+
|
|
664
|
+
func updateUIView(_ uiView: Probe, context: Context) {}
|
|
665
|
+
|
|
666
|
+
final class Probe: UIView {
|
|
667
|
+
let found: (UIScrollView) -> Void
|
|
668
|
+
|
|
669
|
+
init(found: @escaping (UIScrollView) -> Void) {
|
|
670
|
+
self.found = found
|
|
671
|
+
super.init(frame: .zero)
|
|
672
|
+
isUserInteractionEnabled = false
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
required init?(coder: NSCoder) { nil }
|
|
676
|
+
|
|
677
|
+
override func didMoveToWindow() {
|
|
678
|
+
super.didMoveToWindow()
|
|
679
|
+
var view = superview
|
|
680
|
+
while let current = view, !(current is UIScrollView) { view = current.superview }
|
|
681
|
+
if let scrollView = view as? UIScrollView { found(scrollView) }
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/// Calls `onRest` once the scroll comes to rest. Scroll phases need iOS 18; before that the scroll
|
|
687
|
+
/// counts as ended once the offset stops changing, which a finger held still also looks like.
|
|
688
|
+
private struct ScrollRest: ViewModifier {
|
|
689
|
+
let offset: CGFloat
|
|
690
|
+
let onRest: () -> Void
|
|
691
|
+
|
|
692
|
+
@State private var pending: Task<Void, Never>?
|
|
693
|
+
|
|
694
|
+
func body(content: Content) -> some View {
|
|
695
|
+
#if compiler(>=6.0)
|
|
696
|
+
if #available(iOS 18.0, *) {
|
|
697
|
+
content.onScrollPhaseChange { _, phase in
|
|
698
|
+
if phase == .idle { onRest() }
|
|
699
|
+
}
|
|
700
|
+
} else {
|
|
701
|
+
debounced(content)
|
|
702
|
+
}
|
|
703
|
+
#else
|
|
704
|
+
debounced(content)
|
|
705
|
+
#endif
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
private func debounced(_ content: Content) -> some View {
|
|
709
|
+
content
|
|
710
|
+
.onChange(of: offset) { _ in
|
|
711
|
+
pending?.cancel()
|
|
712
|
+
pending = Task { @MainActor in
|
|
713
|
+
try? await Task.sleep(nanoseconds: 150_000_000)
|
|
714
|
+
guard !Task.isCancelled else { return }
|
|
715
|
+
onRest()
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
.onDisappear { pending?.cancel() }
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// MARK: - Animated search header
|
|
723
|
+
|
|
724
|
+
/// Compose's `SearchAnimated`: the search slides from below the header (scroll 0) up into the bar
|
|
725
|
+
/// while its insets grow to clear the back button and header right.
|
|
726
|
+
private struct SearchAnimatedHeader: View {
|
|
727
|
+
let props: InputSearchProps
|
|
728
|
+
let scrollOffset: CGFloat
|
|
729
|
+
let hiddenBack: Bool
|
|
730
|
+
let hasHeaderRight: Bool
|
|
731
|
+
let headerRightWidth: CGFloat
|
|
732
|
+
var useSideBar: Bool = false
|
|
733
|
+
var hasToolkit: Bool = false
|
|
734
|
+
|
|
735
|
+
@Environment(\.screenLayout) private var layout
|
|
736
|
+
@Environment(\.appTheme) private var theme
|
|
737
|
+
@OptionalEnvironmentObject private var navigator: Navigator?
|
|
738
|
+
|
|
739
|
+
var body: some View {
|
|
740
|
+
let scroll = max(scrollOffset, 0)
|
|
741
|
+
|
|
742
|
+
// Insets are measured from the safe-area top; collapsed, the search is centered in the bar row.
|
|
743
|
+
let rowTop = layout.headerStatusBarHeight - layout.insets.top
|
|
744
|
+
let minTop = rowTop + (layout.headerBarHeight - HEADER_HEIGHT) / 2
|
|
745
|
+
let maxTop = layout.headerHeight - layout.insets.top
|
|
746
|
+
let minSide = Spacing.M
|
|
747
|
+
// collapsed on the iPhone Duo status bar row, the search stays between its clusters (48pt back there)
|
|
748
|
+
let clearance = layout.statusBarClearance
|
|
749
|
+
let backWidth: CGFloat = clearance != nil && !useSideBar ? DuoMetrics.controlSize : 28
|
|
750
|
+
let maxStart = (clearance?.left ?? 0) + (hiddenBack || useSideBar ? Spacing.M : backWidth + Spacing.M + Spacing.S)
|
|
751
|
+
let maxEnd = (clearance?.right ?? 0) + (useSideBar && hasToolkit
|
|
752
|
+
? Spacing.M
|
|
753
|
+
: headerRightWidth + (hasHeaderRight ? Spacing.M * 2 : Spacing.M))
|
|
754
|
+
|
|
755
|
+
// one progress for all three, so the field docks with its final insets and never covers the toolkit
|
|
756
|
+
let progress = min(scroll / HEADER_HEIGHT, 1)
|
|
757
|
+
let top = maxTop + (minTop - maxTop) * progress
|
|
758
|
+
let start = minSide + (maxStart - minSide) * progress
|
|
759
|
+
let end = minSide + (maxEnd - minSide) * progress
|
|
760
|
+
|
|
761
|
+
// padding, not a Spacer: on the iPhone Duo status bar row the collapsed top is above the safe area
|
|
762
|
+
InputSearch(props: headerSearchProps(
|
|
763
|
+
props,
|
|
764
|
+
scroll: scroll,
|
|
765
|
+
layout: layout,
|
|
766
|
+
theme: theme,
|
|
767
|
+
pop: navigator.map { navigator in { navigator.pop() } }
|
|
768
|
+
))
|
|
769
|
+
.frame(height: HEADER_HEIGHT)
|
|
770
|
+
.padding(.leading, start)
|
|
771
|
+
.padding(.trailing, end)
|
|
772
|
+
.padding(.top, top)
|
|
773
|
+
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// MARK: - Floating content
|
|
778
|
+
|
|
779
|
+
/// Compose's `FloatingContent`: no FAB props means nothing renders.
|
|
780
|
+
private struct FloatingContent: View {
|
|
781
|
+
let fabProps: FabProps?
|
|
782
|
+
let keyboardSize: CGFloat
|
|
783
|
+
/// With a footer the FAB is anchored to the footer's *layout* top, which does not move
|
|
784
|
+
/// when the band offsets itself over the keyboard — so it rides the same offset.
|
|
785
|
+
let hasFooter: Bool
|
|
786
|
+
let scrollOffset: CGFloat
|
|
787
|
+
|
|
788
|
+
/// Compose keeps the FAB `Spacing.M + AppNavigationBar` above the bottom of the screen
|
|
789
|
+
/// and lifts it with `imePadding()`; here it sits `Spacing.M` above the safe area and
|
|
790
|
+
/// nothing lifts it, so the difference is applied as an offset (see NavigationFooter).
|
|
791
|
+
private var verticalOffset: CGFloat {
|
|
792
|
+
if hasFooter { return NavigationFooter.verticalOffset(keyboardSize: keyboardSize) }
|
|
793
|
+
return NavigationFooter.safeAreaBottom - NavigationFooter.navigationBarInset - keyboardSize
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
var body: some View {
|
|
797
|
+
if let fabProps = fabProps {
|
|
798
|
+
FloatingButton(
|
|
799
|
+
props: fabProps,
|
|
800
|
+
keyboardOffset: 0,
|
|
801
|
+
scrollOffset: scrollOffset
|
|
802
|
+
)
|
|
803
|
+
.padding(.horizontal, Spacing.M)
|
|
804
|
+
.offset(y: verticalOffset)
|
|
805
|
+
.ignoresSafeArea(.keyboard, edges: .bottom)
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// MARK: - Footer
|
|
811
|
+
|
|
812
|
+
/// Compose's `Footer`: the band pinned under the content — surface background, S/M padding,
|
|
813
|
+
/// the navigation-bar reserve, and a 6pt shadow cast up onto the content.
|
|
814
|
+
public struct NavigationFooter: View {
|
|
815
|
+
/// Nothing renders when nil.
|
|
816
|
+
var footerComponent: (() -> AnyView)?
|
|
817
|
+
/// Current keyboard height, measured from the bottom of the screen.
|
|
818
|
+
var keyboardSize: CGFloat
|
|
819
|
+
var transparent: Bool
|
|
820
|
+
var useMargin: Bool
|
|
821
|
+
|
|
822
|
+
public init(
|
|
823
|
+
footerComponent: (() -> AnyView)?,
|
|
824
|
+
keyboardSize: CGFloat = 0,
|
|
825
|
+
transparent: Bool = false,
|
|
826
|
+
useMargin: Bool = true
|
|
827
|
+
) {
|
|
828
|
+
self.footerComponent = footerComponent
|
|
829
|
+
self.keyboardSize = keyboardSize
|
|
830
|
+
self.transparent = transparent
|
|
831
|
+
self.useMargin = useMargin
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
@Environment(\.appTheme) private var theme
|
|
835
|
+
@Environment(\.screenLayout) private var layout
|
|
836
|
+
|
|
837
|
+
/// Read from the window, not from an enclosing `GeometryProxy`: the proxy's bottom
|
|
838
|
+
/// inset grows to the keyboard height while the keyboard is up.
|
|
839
|
+
static var safeAreaBottom: CGFloat {
|
|
840
|
+
UIApplication.shared.connectedScenes
|
|
841
|
+
.compactMap { ($0 as? UIWindowScene)?.keyWindow?.safeAreaInsets.bottom }
|
|
842
|
+
.first ?? 0
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/// Compose's `AppNavigationBar.current` (`getNavigationBarHeight` clamps iOS to 21pt).
|
|
846
|
+
static var navigationBarInset: CGFloat { min(safeAreaBottom, 21) }
|
|
847
|
+
|
|
848
|
+
/// Compose's `bottomPadding + Spacing.S` with the keyboard down. The footer is laid
|
|
849
|
+
/// out inside the safe area, which already covers part of that gap.
|
|
850
|
+
static var bottomPadding: CGFloat {
|
|
851
|
+
max(navigationBarInset + Spacing.S - safeAreaBottom, 0)
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
/// Deliberately an offset and not padding: growing the footer feeds back into SwiftUI's
|
|
855
|
+
/// keyboard avoidance, so a padding-based lift never settles at the right place.
|
|
856
|
+
/// Negative lifts the band over the keyboard; positive shaves the few points by which
|
|
857
|
+
/// the safe area over-reserves compared to Compose. `FloatingContent` rides the same
|
|
858
|
+
/// amount so the FAB keeps its distance above the band.
|
|
859
|
+
///
|
|
860
|
+
/// Natural position is `safeAreaBottom + bottomPadding` above the bottom of the screen;
|
|
861
|
+
/// Compose puts it at `(AppNavigationBar - keyboard).coerceAtLeast(0) + Spacing.S` on
|
|
862
|
+
/// top of the `imePadding()` lift — i.e. `max(navigationBar, keyboard) + Spacing.S`.
|
|
863
|
+
static func verticalOffset(keyboardSize: CGFloat) -> CGFloat {
|
|
864
|
+
(safeAreaBottom + bottomPadding) - (max(navigationBarInset, keyboardSize) + Spacing.S)
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
public var body: some View {
|
|
868
|
+
if let footerComponent = footerComponent {
|
|
869
|
+
let margin: CGFloat = useMargin ? Spacing.S : 0
|
|
870
|
+
VStack(alignment: .leading, spacing: 0) {
|
|
871
|
+
footerComponent()
|
|
872
|
+
}
|
|
873
|
+
.frame(maxWidth: .infinity, alignment: .leading)
|
|
874
|
+
.padding(.top, margin)
|
|
875
|
+
.padding(.horizontal, useMargin ? Spacing.M : 0)
|
|
876
|
+
// without the margin the Spacing.S under the content goes too: negative, so the
|
|
877
|
+
// content sinks into the reserve while the frame keeps its bottom edge
|
|
878
|
+
.padding(.bottom, Self.bottomPadding - (Spacing.S - margin))
|
|
879
|
+
// Compose paints the surface all the way to the bottom of the screen; the
|
|
880
|
+
// footer's own frame stops at the safe area, so only the fill bleeds down.
|
|
881
|
+
// With side insets (iPhone Duo) it stays inside them, off the side bar.
|
|
882
|
+
.background(
|
|
883
|
+
(transparent ? Color.clear : theme.colors.background.surface)
|
|
884
|
+
.ignoresSafeArea(.container, edges: hasSideInsets ? .bottom : [.bottom, .horizontal])
|
|
885
|
+
)
|
|
886
|
+
.overlay(alignment: .top) {
|
|
887
|
+
if !transparent { shadow }
|
|
888
|
+
}
|
|
889
|
+
.offset(y: Self.verticalOffset(keyboardSize: keyboardSize))
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
private var hasSideInsets: Bool { layout.insetLeft > 0 || layout.insetRight > 0 }
|
|
894
|
+
|
|
895
|
+
/// Compose offsets the shadow box by -6dp, so it lands on the content above the
|
|
896
|
+
/// footer rather than on the footer surface itself.
|
|
897
|
+
private var shadow: some View {
|
|
898
|
+
LinearGradient(
|
|
899
|
+
colors: [Color.clear, Color.black.opacity(0.05)],
|
|
900
|
+
startPoint: .top,
|
|
901
|
+
endPoint: .bottom
|
|
902
|
+
)
|
|
903
|
+
.frame(height: 6)
|
|
904
|
+
.offset(y: -6)
|
|
905
|
+
.allowsHitTesting(false)
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
// MARK: - Hide keyboard on tap
|
|
910
|
+
|
|
911
|
+
/// Compose's `Modifier.hideKeyboardOnTap()`. Uses `simultaneousGesture` so the tap is not
|
|
912
|
+
/// consumed — buttons and text fields keep working.
|
|
913
|
+
private struct HideKeyboardOnTap: ViewModifier {
|
|
914
|
+
let enabled: Bool
|
|
915
|
+
|
|
916
|
+
func body(content: Content) -> some View {
|
|
917
|
+
if enabled {
|
|
918
|
+
content.simultaneousGesture(
|
|
919
|
+
TapGesture().onEnded {
|
|
920
|
+
UIApplication.shared.sendAction(
|
|
921
|
+
#selector(UIResponder.resignFirstResponder),
|
|
922
|
+
to: nil, from: nil, for: nil
|
|
923
|
+
)
|
|
924
|
+
}
|
|
925
|
+
)
|
|
926
|
+
} else {
|
|
927
|
+
content
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
}
|