@momo-kits/native-kits 0.163.1-beta.8-debug → 0.163.1-beta.9-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 +1 -1
- package/compose/build.gradle.kts +1 -1
- package/compose/compose.podspec +1 -1
- 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 +30 -3
- package/compose/src/commonMain/kotlin/vn/momo/kits/application/Context.kt +5 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderRight.kt +1 -1
- package/compose/src/commonMain/kotlin/vn/momo/kits/application/NavigationContainer.kt +3 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/application/WidgetContainer.kt +56 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/ScaleSizeScope.kt +4 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/const/KitFontScale.kt +22 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/const/Typography.kt +8 -2
- package/compose/src/commonMain/kotlin/vn/momo/kits/layout/Card.kt +63 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/layout/Item.kt +32 -19
- package/compose/src/commonMain/kotlin/vn/momo/kits/layout/Section.kt +49 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/BottomSheet.kt +1 -1
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/ModalScreen.kt +1 -1
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigation.kt +3 -1
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/NavigationContainer.kt +93 -75
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/StackScreen.kt +26 -7
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTab.kt +3 -1
- package/compose/src/commonMain/kotlin/vn/momo/kits/platform/ComposeLottieAnimation.kt +23 -2
- package/compose/src/commonMain/kotlin/vn/momo/kits/platform/OsFontScale.kt +9 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/platform/Platform.kt +11 -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 +26 -3
- package/gradle.properties +1 -1
- package/ios/Application/ApplicationEnvironment.swift +15 -2
- package/ios/Application/Components.swift +50 -44
- package/ios/Application/FloatingButton.swift +10 -9
- package/ios/Application/HeaderRight.swift +79 -45
- package/ios/Application/Localize.swift +277 -0
- package/ios/Application/MaxApi.swift +18 -0
- package/ios/Application/Navigation/BottomTab/BottomTab.swift +104 -0
- package/ios/Application/Navigation/BottomTab/BottomTabBar.swift +215 -0
- package/ios/Application/Navigation/BottomTab/CurvedContainer.swift +65 -0
- package/ios/Application/Navigation/HeaderBackProps.swift +66 -0
- package/ios/Application/Navigation/HeaderTitle.swift +57 -0
- package/ios/Application/Navigation/HeaderUser.swift +209 -0
- package/ios/Application/Navigation/NavigationContainer.swift +200 -0
- package/ios/Application/Navigation/NavigationOptions.swift +109 -0
- package/ios/Application/Navigation/Navigator.swift +385 -0
- package/ios/Application/Navigation/Overplay/BottomSheet.swift +256 -0
- package/ios/Application/Navigation/Overplay/ModalScreen.swift +74 -0
- package/ios/Application/Navigation/Overplay/SnackBar.swift +144 -0
- package/ios/Application/Navigation/Tracking/ScreenTracker.swift +117 -0
- package/ios/Application/Navigation/component/Header.swift +178 -0
- package/ios/Application/Navigation/component/HeaderBackground.swift +83 -0
- package/ios/Application/Navigation/component/HeaderColor.swift +65 -0
- package/ios/Application/Screen.swift +2 -1
- package/ios/Application/StackScreen.swift +501 -0
- package/ios/Avatar/Avatar.swift +193 -0
- package/ios/Badge/Badge.swift +11 -14
- package/ios/Badge/BadgeRibbon.swift +92 -21
- package/ios/BaselineView/BaselineView.swift +163 -0
- package/ios/Button/Button.swift +124 -29
- package/ios/Carousel/Carousel.swift +196 -0
- package/ios/Checkbox/Checkbox.swift +23 -28
- package/ios/Chip/Chip.swift +56 -33
- package/ios/Collapse/Collapse.swift +192 -0
- package/ios/Colors+Radius+Spacing/Colors.swift +20 -17
- package/ios/Colors+Radius+Spacing/Spacing.swift +3 -1
- package/ios/Colors+Radius+Spacing/Theme.swift +295 -0
- package/ios/DateTimePicker/DateTimePicker.swift +212 -0
- package/ios/DateTimePicker/DateTimePickerTypes.swift +55 -0
- package/ios/DateTimePicker/DateTimePickerUtils.swift +167 -0
- package/ios/DateTimePicker/WheelPicker.swift +165 -0
- package/ios/Divider/Divider.swift +16 -0
- package/ios/Extensions/ActiveOpacityButtonStyle.swift +14 -0
- package/ios/Extensions/Color++.swift +32 -0
- package/ios/Icon/Icon.swift +10 -1
- package/ios/IconButton/IconButton.swift +140 -0
- package/ios/Image/Image.swift +16 -3
- package/ios/Information/Information.swift +3 -1
- package/ios/Input/ErrorView.swift +3 -1
- package/ios/Input/Input.swift +72 -33
- package/ios/Input/InputPhoneNumber.swift +33 -21
- package/ios/Input/InputSearch.swift +107 -48
- package/ios/Input/InputTextArea.swift +134 -64
- package/ios/InputDropDown/InputDropDown.swift +201 -0
- package/ios/InputMoney/InputMoney.swift +347 -0
- package/ios/InputOTP/InputOTP.swift +210 -0
- package/ios/Loader/Loader.swift +113 -0
- package/ios/MoMoUIKits.podspec +5 -0
- package/ios/Pagination/PaginationDot.swift +61 -0
- package/ios/Pagination/PaginationNumber.swift +35 -0
- package/ios/Pagination/PaginationScroll.swift +101 -0
- package/ios/Pagination/PaginationWhiteDot.swift +37 -0
- package/ios/Popup/PopupDisplay.swift +11 -20
- package/ios/Popup/PopupInput.swift +2 -8
- package/ios/Popup/PopupNotify.swift +218 -0
- package/ios/Popup/PopupPromotion.swift +16 -8
- package/ios/ProgressInfo/ProgressInfo.swift +296 -0
- package/ios/Radio/Radio.swift +69 -0
- package/ios/Rating/Rating.swift +82 -0
- package/ios/Resources/MoMoUIKitsColors.xcassets/AccentColor.colorset/Contents.json +11 -0
- package/ios/Resources/MoMoUIKitsColors.xcassets/Background.colorset/Contents.json +38 -0
- package/ios/Resources/MoMoUIKitsColors.xcassets/Border.colorset/Contents.json +38 -0
- package/ios/Resources/MoMoUIKitsColors.xcassets/Card.colorset/Contents.json +38 -0
- package/ios/Resources/MoMoUIKitsColors.xcassets/Contents.json +6 -0
- package/ios/Resources/MoMoUIKitsColors.xcassets/Error.colorset/Contents.json +38 -0
- package/ios/Resources/MoMoUIKitsColors.xcassets/Primary.colorset/Contents.json +38 -0
- package/ios/Resources/MoMoUIKitsColors.xcassets/PrimaryDark.colorset/Contents.json +38 -0
- package/ios/Resources/MoMoUIKitsColors.xcassets/PrimaryLight.colorset/Contents.json +38 -0
- package/ios/Resources/MoMoUIKitsColors.xcassets/Secondary.colorset/Contents.json +38 -0
- package/ios/Resources/MoMoUIKitsColors.xcassets/Success.colorset/Contents.json +38 -0
- package/ios/Resources/MoMoUIKitsColors.xcassets/Text.colorset/Contents.json +38 -0
- package/ios/Resources/MoMoUIKitsColors.xcassets/TextSecondary.colorset/Contents.json +38 -0
- package/ios/Resources/MoMoUIKitsColors.xcassets/ThemeColors/Contents.json +9 -0
- package/ios/Resources/MoMoUIKitsColors.xcassets/ThemeColors/background-default.colorset/Contents.json +38 -0
- package/ios/Resources/MoMoUIKitsColors.xcassets/ThemeColors/background-disable.colorset/Contents.json +38 -0
- package/ios/Resources/MoMoUIKitsColors.xcassets/ThemeColors/background-pressed.colorset/Contents.json +38 -0
- package/ios/Resources/MoMoUIKitsColors.xcassets/ThemeColors/background-selected.colorset/Contents.json +38 -0
- package/ios/Resources/MoMoUIKitsColors.xcassets/ThemeColors/background-surface.colorset/Contents.json +38 -0
- package/ios/Resources/MoMoUIKitsColors.xcassets/ThemeColors/background-tonal.colorset/Contents.json +38 -0
- package/ios/Resources/MoMoUIKitsColors.xcassets/ThemeColors/border-default.colorset/Contents.json +38 -0
- package/ios/Resources/MoMoUIKitsColors.xcassets/ThemeColors/border-disable.colorset/Contents.json +38 -0
- package/ios/Resources/MoMoUIKitsColors.xcassets/ThemeColors/gradient.colorset/Contents.json +38 -0
- package/ios/Resources/MoMoUIKitsColors.xcassets/ThemeColors/primary.colorset/Contents.json +38 -0
- package/ios/Resources/MoMoUIKitsColors.xcassets/ThemeColors/secondary.colorset/Contents.json +38 -0
- package/ios/Resources/MoMoUIKitsColors.xcassets/ThemeColors/success-container.colorset/Contents.json +38 -0
- package/ios/Resources/MoMoUIKitsColors.xcassets/ThemeColors/success-primary.colorset/Contents.json +38 -0
- package/ios/Resources/MoMoUIKitsColors.xcassets/ThemeColors/success-secondary.colorset/Contents.json +38 -0
- package/ios/Resources/MoMoUIKitsColors.xcassets/ThemeColors/text-default.colorset/Contents.json +38 -0
- package/ios/Resources/MoMoUIKitsColors.xcassets/ThemeColors/text-disable.colorset/Contents.json +38 -0
- package/ios/Resources/MoMoUIKitsColors.xcassets/ThemeColors/text-hint.colorset/Contents.json +38 -0
- package/ios/Resources/MoMoUIKitsColors.xcassets/ThemeColors/text-secondary.colorset/Contents.json +38 -0
- package/ios/Resources/MoMoUIKitsColors.xcassets/Warning.colorset/Contents.json +38 -0
- package/ios/Skeleton/Skeleton.swift +99 -0
- package/ios/Slider/Slider.swift +237 -0
- package/ios/Stepper/Stepper.swift +220 -0
- package/ios/Steps/Steps.swift +467 -0
- package/ios/SuggestAction/SuggestAction.swift +99 -0
- package/ios/Swipeable/SwipeCell.swift +187 -0
- package/ios/Swipeable/SwipeCellModel.swift +21 -0
- package/ios/Switch/Switch.swift +53 -28
- package/ios/TabView/TabView.swift +534 -0
- package/ios/Tag/Tag.swift +98 -0
- package/ios/Template/TrustBanner/TrustBanner.swift +6 -7
- package/ios/Title/Title.swift +269 -0
- package/ios/Tooltip/Tooltip.swift +485 -0
- package/ios/Typography/FontScaleStore.swift +19 -0
- package/ios/Typography/Text.swift +79 -5
- package/ios/Typography/Typography.swift +3 -0
- package/ios/Uploader/Uploader.swift +174 -0
- package/ios/native-kits.podspec +61 -8
- package/ios-demo/MoMoUIKitsDemo.podspec +20 -0
- package/ios-demo/README.md +120 -0
- package/ios-demo/Resources/dot_loading.json +558 -0
- package/ios-demo/Resources/icon.json +4052 -0
- package/ios-demo/Resources/lottie_circle_loader.json +1 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/DemoScaffold.swift +48 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/Demos/UIKitsBadgeDemoView.swift +25 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/Demos/UIKitsButtonDemoView.swift +26 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/Demos/UIKitsCheckboxDemoView.swift +19 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/Demos/UIKitsChipDemoView.swift +33 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/Demos/UIKitsInputDemoView.swift +48 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/Demos/UIKitsPopupDemoView.swift +21 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/Demos/UIKitsRadioDemoView.swift +17 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/Demos/UIKitsTypographyDemoView.swift +28 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/MoMoUIKitsDemoContext.swift +31 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/MoMoUIKitsDemoFactory.swift +24 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/AnimatedHeaderDemo.swift +110 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/AvatarDemo.swift +93 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/BadgeDemo.swift +269 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/BaselineView.swift +117 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/BottomTabDemo.swift +89 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/ButtonDemo.swift +100 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/CarouselDemo.swift +126 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/CheckboxDemo.swift +63 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/ChipDemo.swift +20 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/CollapseDemo.swift +93 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/ContentView.swift +107 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/DateTimePickerDemo.swift +124 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/DividerDemo.swift +14 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/HeaderUserDemo.swift +49 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/Home.swift +279 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/IconButtonDemo.swift +58 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/ImageDemo.swift +78 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/InformationDemo.swift +138 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/InputDemo.swift +111 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/InputDropDownDemo.swift +136 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/InputMoneyDemo.swift +107 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/InputOTPDemo.swift +73 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/InputPhoneNumberDemo.swift +100 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/InputSearchDemo.swift +32 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/InputTextAreaDemo.swift +57 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/LoaderDemo.swift +70 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/LocalizeDemo.swift +188 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/NavigationDemo.swift +229 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PaginationDemo.swift +72 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PopupDisplayDemo.swift +175 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PopupNotifyDemo.swift +121 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PopupPromotionDemo.swift +76 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/ProgressInfoDemo.swift +89 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/RadioDemo.swift +32 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/RatingDemo.swift +56 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/SkeletonDemo.swift +32 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/SliderDemo.swift +150 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/SnackBarDemo.swift +138 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/StepperDemo.swift +103 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/StepsDemo.swift +94 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/SuggestActionDemo.swift +79 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/SwipeDemo.swift +152 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/SwitchDemo.swift +33 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/TabViewDemo.swift +190 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/TagDemo.swift +34 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/ThemeDemo.swift +182 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/TitleDemo.swift +109 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/TooltipDemo.swift +187 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/TypographyDemo.swift +87 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/UploaderDemo.swift +131 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/SwiftUIDemoBrowserView.swift +51 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/UIKitsDemoModels.swift +15 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/UIKitsDemoRegistry.swift +73 -0
- package/local.properties +2 -2
- package/package.json +1 -1
- package/publish.sh +53 -0
- package/scripts/gen-ios-color.mjs +136 -0
- package/.claude/settings.local.json +0 -62
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Navigator.swift
|
|
3
|
+
// MoMoUIKits
|
|
4
|
+
//
|
|
5
|
+
// SwiftUI mirror of Compose's Navigator + DynamicScreenRegistry.
|
|
6
|
+
// Screens are registered by Int id; the NavigationStack path stores routes
|
|
7
|
+
// by id and looks up content from the registry on render.
|
|
8
|
+
//
|
|
9
|
+
|
|
10
|
+
import SwiftUI
|
|
11
|
+
import Combine
|
|
12
|
+
|
|
13
|
+
// MARK: - Routes
|
|
14
|
+
|
|
15
|
+
public struct DynamicScreenRoute: Hashable, Identifiable {
|
|
16
|
+
public let id: Int
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
public struct DynamicDialogRoute: Hashable, Identifiable {
|
|
20
|
+
public let id: Int
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// MARK: - Registry
|
|
24
|
+
|
|
25
|
+
public struct DynamicScreen {
|
|
26
|
+
public let id: Int
|
|
27
|
+
public let name: String
|
|
28
|
+
public let content: () -> AnyView
|
|
29
|
+
public var options: NavigationOptions?
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
public final class DynamicScreenRegistry {
|
|
33
|
+
private var screens: [Int: DynamicScreen] = [:]
|
|
34
|
+
private var idCounter: Int = 1
|
|
35
|
+
|
|
36
|
+
public init() {}
|
|
37
|
+
|
|
38
|
+
@discardableResult
|
|
39
|
+
public func register(
|
|
40
|
+
screenName: String = "",
|
|
41
|
+
content: @escaping () -> AnyView,
|
|
42
|
+
options: NavigationOptions? = nil
|
|
43
|
+
) -> DynamicScreenRoute {
|
|
44
|
+
let id = idCounter
|
|
45
|
+
idCounter += 1
|
|
46
|
+
screens[id] = DynamicScreen(id: id, name: screenName, content: content, options: options)
|
|
47
|
+
return DynamicScreenRoute(id: id)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
public func unregister(id: Int) {
|
|
51
|
+
screens.removeValue(forKey: id)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
public func unregisterAll() {
|
|
55
|
+
screens.removeAll()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
public func getScreen(id: Int) -> DynamicScreen? { screens[id] }
|
|
59
|
+
|
|
60
|
+
public func latest() -> DynamicScreen? {
|
|
61
|
+
screens.max(by: { $0.key < $1.key })?.value
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
public func setOptions(id: Int, options: NavigationOptions) {
|
|
65
|
+
screens[id]?.options = options
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// MARK: - Overplay
|
|
70
|
+
|
|
71
|
+
public enum OverplayType {
|
|
72
|
+
case modal, bottomSheet, snackBar
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
public struct OverplayItem: Identifiable {
|
|
76
|
+
public let id = UUID()
|
|
77
|
+
public let type: OverplayType
|
|
78
|
+
public let content: () -> AnyView
|
|
79
|
+
public let barrierDismissible: Bool
|
|
80
|
+
public let onDismiss: (() -> Void)?
|
|
81
|
+
// Bottom-sheet params (mirrors Compose's OverplayComponentParams.BottomSheet).
|
|
82
|
+
public var isSurface: Bool = false
|
|
83
|
+
public var header: BottomSheetHeader? = nil
|
|
84
|
+
// Snack-bar payload (mirrors Compose's SnackBar value).
|
|
85
|
+
public var snackBar: SnackBar? = nil
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// MARK: - Navigator
|
|
89
|
+
|
|
90
|
+
@available(iOS 16.0, *)
|
|
91
|
+
public final class Navigator: ObservableObject {
|
|
92
|
+
@Published public var path: [DynamicScreenRoute] = []
|
|
93
|
+
@Published public var presented: DynamicDialogRoute?
|
|
94
|
+
@Published public var overplay: OverplayItem?
|
|
95
|
+
|
|
96
|
+
/// Owned per-Navigator (mirrors Compose's `remember { DynamicScreenRegistry() }` +
|
|
97
|
+
/// disposal): screens registered by this navigator are released via ARC when the
|
|
98
|
+
/// navigator itself deallocates, instead of accumulating in a process-wide singleton.
|
|
99
|
+
public let registry = DynamicScreenRegistry()
|
|
100
|
+
|
|
101
|
+
/// Animated-close handler registered by the active overplay component
|
|
102
|
+
/// (mirrors Compose's `OverplayComponentRegistry.bindClose`). When set, a
|
|
103
|
+
/// dismiss request runs the component's exit animation before the item is
|
|
104
|
+
/// actually removed via `finishOverplayDismiss`.
|
|
105
|
+
private var overplayDismissHandler: (() -> Void)?
|
|
106
|
+
|
|
107
|
+
public private(set) var rootRoute: DynamicScreenRoute
|
|
108
|
+
public var composeApi: KitComposeApi?
|
|
109
|
+
|
|
110
|
+
public init(
|
|
111
|
+
initialScreenName: String = "",
|
|
112
|
+
initialContent: @escaping () -> AnyView,
|
|
113
|
+
options: NavigationOptions? = nil,
|
|
114
|
+
composeApi: KitComposeApi? = nil
|
|
115
|
+
) {
|
|
116
|
+
self.composeApi = composeApi
|
|
117
|
+
self.rootRoute = registry.register(
|
|
118
|
+
screenName: initialScreenName,
|
|
119
|
+
content: initialContent,
|
|
120
|
+
options: options
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// MARK: Stack
|
|
125
|
+
|
|
126
|
+
public func push<V: View>(@ViewBuilder _ content: @escaping () -> V, options: NavigationOptions? = nil) {
|
|
127
|
+
let route = registry.register(
|
|
128
|
+
screenName: options?.screenName ?? "",
|
|
129
|
+
content: { AnyView(content()) },
|
|
130
|
+
options: options
|
|
131
|
+
)
|
|
132
|
+
path.append(route)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
public func push<V: View>(
|
|
136
|
+
screenName: String,
|
|
137
|
+
@ViewBuilder _ content: @escaping () -> V,
|
|
138
|
+
options: NavigationOptions? = nil
|
|
139
|
+
) {
|
|
140
|
+
let route = registry.register(
|
|
141
|
+
screenName: screenName,
|
|
142
|
+
content: { AnyView(content()) },
|
|
143
|
+
options: options
|
|
144
|
+
)
|
|
145
|
+
path.append(route)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
public func replace<V: View>(@ViewBuilder _ content: @escaping () -> V, options: NavigationOptions? = nil) {
|
|
149
|
+
guard !path.isEmpty else { return }
|
|
150
|
+
let removed = path.removeLast()
|
|
151
|
+
registry.unregister(id: removed.id)
|
|
152
|
+
push(content, options: options)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
public func replace<V: View>(
|
|
156
|
+
screenName: String,
|
|
157
|
+
@ViewBuilder _ content: @escaping () -> V,
|
|
158
|
+
options: NavigationOptions? = nil
|
|
159
|
+
) {
|
|
160
|
+
guard !path.isEmpty else { return }
|
|
161
|
+
let removed = path.removeLast()
|
|
162
|
+
registry.unregister(id: removed.id)
|
|
163
|
+
push(screenName: screenName, content, options: options)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
public func reset<V: View>(@ViewBuilder _ content: @escaping () -> V, options: NavigationOptions? = nil) {
|
|
167
|
+
for route in path { registry.unregister(id: route.id) }
|
|
168
|
+
registry.unregister(id: rootRoute.id)
|
|
169
|
+
path.removeAll()
|
|
170
|
+
let route = registry.register(
|
|
171
|
+
screenName: options?.screenName ?? "",
|
|
172
|
+
content: { AnyView(content()) },
|
|
173
|
+
options: options
|
|
174
|
+
)
|
|
175
|
+
rootRoute = route
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
public func reset<V: View>(
|
|
179
|
+
screenName: String,
|
|
180
|
+
@ViewBuilder _ content: @escaping () -> V,
|
|
181
|
+
options: NavigationOptions? = nil
|
|
182
|
+
) {
|
|
183
|
+
for route in path { registry.unregister(id: route.id) }
|
|
184
|
+
registry.unregister(id: rootRoute.id)
|
|
185
|
+
path.removeAll()
|
|
186
|
+
let route = registry.register(
|
|
187
|
+
screenName: screenName,
|
|
188
|
+
content: { AnyView(content()) },
|
|
189
|
+
options: options
|
|
190
|
+
)
|
|
191
|
+
rootRoute = route
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
public func pop(_ count: Int = 1, callback: (() -> Void)? = nil) {
|
|
195
|
+
var remaining = count
|
|
196
|
+
while remaining > 0 {
|
|
197
|
+
if overplay?.type == .snackBar {
|
|
198
|
+
// Mirrors Kotlin: a snackbar doesn't block back-navigation, so popping
|
|
199
|
+
// while one is showing pops the screen underneath and clears the
|
|
200
|
+
// snackbar immediately, without playing its dismiss animation.
|
|
201
|
+
overplayDismissHandler = nil
|
|
202
|
+
overplay = nil
|
|
203
|
+
if !popScreen() { break }
|
|
204
|
+
} else if overplay != nil {
|
|
205
|
+
hideOverplay()
|
|
206
|
+
} else if !popScreen() {
|
|
207
|
+
break
|
|
208
|
+
}
|
|
209
|
+
remaining -= 1
|
|
210
|
+
}
|
|
211
|
+
callback?()
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/// Pops the topmost presented screen or stack entry. Returns `false` (and requests
|
|
215
|
+
/// a host dismiss) when there is nothing left to pop.
|
|
216
|
+
@discardableResult
|
|
217
|
+
private func popScreen() -> Bool {
|
|
218
|
+
if presented != nil {
|
|
219
|
+
let dismissedId = presented?.id
|
|
220
|
+
presented = nil
|
|
221
|
+
if let id = dismissedId { registry.unregister(id: id) }
|
|
222
|
+
return true
|
|
223
|
+
} else if !path.isEmpty {
|
|
224
|
+
let removed = path.removeLast()
|
|
225
|
+
registry.unregister(id: removed.id)
|
|
226
|
+
return true
|
|
227
|
+
} else {
|
|
228
|
+
composeApi?.requestCallback(funcName: "dismiss", params: nil, onResponse: nil)
|
|
229
|
+
return false
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
public func popToRoot() {
|
|
234
|
+
for route in path { registry.unregister(id: route.id) }
|
|
235
|
+
path.removeAll()
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
public func onBackSafe(callback: (() -> Void)? = nil) {
|
|
239
|
+
let latest = registry.latest()
|
|
240
|
+
if let onBack = latest?.options?.onBackHandler {
|
|
241
|
+
onBack()
|
|
242
|
+
return
|
|
243
|
+
}
|
|
244
|
+
if let preventBack = latest?.options?.headerBackProps?.preventBack {
|
|
245
|
+
showModal {
|
|
246
|
+
PopupDisplay(
|
|
247
|
+
isPresented: .constant(true),
|
|
248
|
+
title: preventBack.title,
|
|
249
|
+
description: preventBack.description,
|
|
250
|
+
url: preventBack.image,
|
|
251
|
+
buttonDirection: preventBack.buttonDirection,
|
|
252
|
+
buttonType: .button,
|
|
253
|
+
actionButtonTitle: preventBack.primary?.title ?? "",
|
|
254
|
+
closeButtonTitle: preventBack.secondary?.title ?? "",
|
|
255
|
+
onPressAction: { [weak self] in
|
|
256
|
+
preventBack.primary?.onPress?()
|
|
257
|
+
// Close the popup, then leave the screen (matches Compose's pop(2)).
|
|
258
|
+
self?.pop(2, callback: callback)
|
|
259
|
+
},
|
|
260
|
+
onPressCloseButton: { [weak self] in
|
|
261
|
+
preventBack.secondary?.onPress?()
|
|
262
|
+
self?.hideOverplay()
|
|
263
|
+
},
|
|
264
|
+
onClose: { [weak self] in
|
|
265
|
+
preventBack.onIconClose?()
|
|
266
|
+
self?.hideOverplay()
|
|
267
|
+
},
|
|
268
|
+
errorCode: preventBack.error,
|
|
269
|
+
isShowCloseIcon: preventBack.isShowCloseIcon
|
|
270
|
+
)
|
|
271
|
+
}
|
|
272
|
+
return
|
|
273
|
+
}
|
|
274
|
+
pop(1, callback: callback)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// MARK: Modal-style screen (vertical slide)
|
|
278
|
+
|
|
279
|
+
public func present<V: View>(@ViewBuilder _ content: @escaping () -> V, options: NavigationOptions? = nil) {
|
|
280
|
+
let route = registry.register(
|
|
281
|
+
screenName: options?.screenName ?? "",
|
|
282
|
+
content: { AnyView(content()) },
|
|
283
|
+
options: options
|
|
284
|
+
)
|
|
285
|
+
presented = DynamicDialogRoute(id: route.id)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
public func present<V: View>(
|
|
289
|
+
screenName: String,
|
|
290
|
+
@ViewBuilder _ content: @escaping () -> V,
|
|
291
|
+
options: NavigationOptions? = nil
|
|
292
|
+
) {
|
|
293
|
+
let route = registry.register(
|
|
294
|
+
screenName: screenName,
|
|
295
|
+
content: { AnyView(content()) },
|
|
296
|
+
options: options
|
|
297
|
+
)
|
|
298
|
+
presented = DynamicDialogRoute(id: route.id)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
public func dismiss() {
|
|
302
|
+
if let id = presented?.id {
|
|
303
|
+
registry.unregister(id: id)
|
|
304
|
+
}
|
|
305
|
+
presented = nil
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// MARK: Overplay
|
|
309
|
+
|
|
310
|
+
public func showModal<V: View>(
|
|
311
|
+
@ViewBuilder _ content: @escaping () -> V,
|
|
312
|
+
barrierDismissible: Bool = true,
|
|
313
|
+
onDismiss: (() -> Void)? = nil
|
|
314
|
+
) {
|
|
315
|
+
overplayDismissHandler = nil
|
|
316
|
+
overplay = OverplayItem(
|
|
317
|
+
type: .modal,
|
|
318
|
+
content: { AnyView(content()) },
|
|
319
|
+
barrierDismissible: barrierDismissible,
|
|
320
|
+
onDismiss: onDismiss
|
|
321
|
+
)
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
public func showBottomSheet<V: View>(
|
|
325
|
+
@ViewBuilder _ content: @escaping () -> V,
|
|
326
|
+
isSurface: Bool = false,
|
|
327
|
+
barrierDismissible: Bool = true,
|
|
328
|
+
header: BottomSheetHeader? = nil,
|
|
329
|
+
onDismiss: (() -> Void)? = nil
|
|
330
|
+
) {
|
|
331
|
+
overplayDismissHandler = nil
|
|
332
|
+
overplay = OverplayItem(
|
|
333
|
+
type: .bottomSheet,
|
|
334
|
+
content: { AnyView(content()) },
|
|
335
|
+
barrierDismissible: barrierDismissible,
|
|
336
|
+
onDismiss: onDismiss,
|
|
337
|
+
isSurface: isSurface,
|
|
338
|
+
header: header
|
|
339
|
+
)
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/// Mirrors Compose's `showSnackBar(snackBar:)`.
|
|
343
|
+
public func showSnackBar(_ snackBar: SnackBar, onDismiss: (() -> Void)? = nil) {
|
|
344
|
+
overplayDismissHandler = nil
|
|
345
|
+
overplay = OverplayItem(
|
|
346
|
+
type: .snackBar,
|
|
347
|
+
content: { AnyView(EmptyView()) },
|
|
348
|
+
barrierDismissible: false,
|
|
349
|
+
onDismiss: onDismiss,
|
|
350
|
+
snackBar: snackBar
|
|
351
|
+
)
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// MARK: Overplay dismissal
|
|
355
|
+
|
|
356
|
+
/// Registered by the active overplay component so external dismiss requests
|
|
357
|
+
/// run its exit animation (mirrors Compose's `bindClose`).
|
|
358
|
+
public func registerOverplayDismiss(_ handler: @escaping () -> Void) {
|
|
359
|
+
overplayDismissHandler = handler
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/// Removes the overplay and fires its `onDismiss` (mirrors Compose's
|
|
363
|
+
/// `hardClearAfterDismiss`). Called by a component once its exit animation
|
|
364
|
+
/// has completed.
|
|
365
|
+
public func finishOverplayDismiss() {
|
|
366
|
+
let dismissCallback = overplay?.onDismiss
|
|
367
|
+
overplayDismissHandler = nil
|
|
368
|
+
overplay = nil
|
|
369
|
+
dismissCallback?()
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
public func hideOverplay() {
|
|
373
|
+
if let close = overplayDismissHandler {
|
|
374
|
+
// Component runs its exit animation, then calls finishOverplayDismiss.
|
|
375
|
+
overplayDismissHandler = nil
|
|
376
|
+
close()
|
|
377
|
+
} else {
|
|
378
|
+
finishOverplayDismiss()
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
public func hideSnackBar() {
|
|
383
|
+
if overplay?.type == .snackBar { hideOverplay() }
|
|
384
|
+
}
|
|
385
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
//
|
|
2
|
+
// BottomSheet.swift
|
|
3
|
+
// MoMoUIKits
|
|
4
|
+
//
|
|
5
|
+
// SwiftUI mirror of Compose's `BottomSheet` (navigation/BottomSheet.kt).
|
|
6
|
+
// A sheet that slides up from the bottom over a dimmed scrim, with a drag
|
|
7
|
+
// handle, a header (title or custom), a divider and the content body.
|
|
8
|
+
// Dragging down past a threshold (or tapping the scrim) dismisses it.
|
|
9
|
+
// Driven by `Navigator.showBottomSheet`.
|
|
10
|
+
//
|
|
11
|
+
|
|
12
|
+
import SwiftUI
|
|
13
|
+
|
|
14
|
+
// MARK: - Header model
|
|
15
|
+
|
|
16
|
+
/// Mirrors Compose's `sealed class BottomHeader` (Title / Custom).
|
|
17
|
+
public struct BottomSheetHeader {
|
|
18
|
+
enum Style {
|
|
19
|
+
case title(String)
|
|
20
|
+
case custom(() -> AnyView)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let style: Style
|
|
24
|
+
|
|
25
|
+
/// Mirrors `BottomHeader.Title`.
|
|
26
|
+
public static func title(_ text: String = "Bottom Sheet Title") -> BottomSheetHeader {
|
|
27
|
+
BottomSheetHeader(style: .title(text))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/// Mirrors `BottomHeader.Custom`.
|
|
31
|
+
public static func custom<V: View>(@ViewBuilder _ content: @escaping () -> V) -> BottomSheetHeader {
|
|
32
|
+
BottomSheetHeader(style: .custom({ AnyView(content()) }))
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// MARK: - Bottom sheet view
|
|
37
|
+
|
|
38
|
+
@available(iOS 16.0, *)
|
|
39
|
+
struct BottomSheetView<Content: View>: View {
|
|
40
|
+
let header: BottomSheetHeader
|
|
41
|
+
let isSurface: Bool
|
|
42
|
+
let barrierDismissible: Bool
|
|
43
|
+
let content: () -> Content
|
|
44
|
+
|
|
45
|
+
@EnvironmentObject private var navigator: Navigator
|
|
46
|
+
@Environment(\.appTheme) private var theme
|
|
47
|
+
@Environment(\.applicationEnvironment) private var appEnv
|
|
48
|
+
|
|
49
|
+
/// Vertical translation of the sheet; starts off-screen and animates to 0.
|
|
50
|
+
@State private var sheetOffset: CGFloat = UIScreen.main.bounds.height
|
|
51
|
+
/// Mirrors Compose's `backgroundAlpha`: only touched by open()/close(), never by drag.
|
|
52
|
+
@State private var backgroundAlpha: CGFloat = 0
|
|
53
|
+
@State private var sheetHeight: CGFloat = 0
|
|
54
|
+
@State private var didAppear = false
|
|
55
|
+
@State private var keyboardHeight: CGFloat = 0
|
|
56
|
+
/// Live drag offset, isolated from `sheetOffset` so a touch-move only invalidates
|
|
57
|
+
/// this gesture-scoped value instead of forcing the whole body (including
|
|
58
|
+
/// `content()`) to reconstruct every frame — mirrors the pattern already used in
|
|
59
|
+
/// `ios/PopupView/PopupView.swift` for smooth drag-to-dismiss.
|
|
60
|
+
@GestureState private var dragTranslation: CGFloat = 0
|
|
61
|
+
|
|
62
|
+
/// Mirrors Compose's `sheetCloseOffset` (100dp drag threshold to dismiss).
|
|
63
|
+
private let closeThreshold: CGFloat = 100
|
|
64
|
+
|
|
65
|
+
/// Mirrors Compose's per-term peak (0.3f). Combined with `backgroundAlpha` the
|
|
66
|
+
/// steady-state (fully open) scrim peaks at 0.6, matching ModalScreen's peak.
|
|
67
|
+
private let maxDynamicAlpha: CGFloat = 0.3
|
|
68
|
+
|
|
69
|
+
private var screenHeight: CGFloat { UIScreen.main.bounds.height }
|
|
70
|
+
private var bottomInset: CGFloat { safeAreaBottomInset() }
|
|
71
|
+
/// While the keyboard is up, it already covers the home-indicator area, so it
|
|
72
|
+
/// replaces (rather than adds to) the usual bottom-inset filler.
|
|
73
|
+
private var effectiveBottomInset: CGFloat { keyboardHeight > 0 ? keyboardHeight : bottomInset }
|
|
74
|
+
|
|
75
|
+
private var backgroundColor: Color {
|
|
76
|
+
isSurface ? theme.colors.background.surface : theme.colors.background.default
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private var effectiveOffset: CGFloat { sheetOffset + dragTranslation }
|
|
80
|
+
|
|
81
|
+
/// Mirrors Compose's `dynamicAlpha`: a plain derived value (not itself animated)
|
|
82
|
+
/// that tracks the current offset live, shrinking toward 0 as the sheet is
|
|
83
|
+
/// dragged down.
|
|
84
|
+
private var dynamicAlpha: CGFloat {
|
|
85
|
+
let denom = sheetHeight > 0 ? sheetHeight : screenHeight
|
|
86
|
+
guard denom > 0 else { return maxDynamicAlpha }
|
|
87
|
+
let progress = min(max(1 - effectiveOffset / denom, 0), 1)
|
|
88
|
+
return progress * maxDynamicAlpha
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/// Mirrors Compose's `alpha = backgroundAlpha + dynamicAlpha`: additive, so the
|
|
92
|
+
/// scrim peaks at 0.6 at rest and only fades to a 0.3 floor while dragging
|
|
93
|
+
/// (backgroundAlpha holds steady until an explicit close()).
|
|
94
|
+
private var totalScrimAlpha: CGFloat { backgroundAlpha + dynamicAlpha }
|
|
95
|
+
|
|
96
|
+
var body: some View {
|
|
97
|
+
ZStack(alignment: .bottom) {
|
|
98
|
+
// Dimmed scrim — additive backgroundAlpha + dynamicAlpha (see above).
|
|
99
|
+
Color.black.opacity(totalScrimAlpha)
|
|
100
|
+
.contentShape(Rectangle())
|
|
101
|
+
.onTapGesture {
|
|
102
|
+
if barrierDismissible { close() }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
sheet
|
|
106
|
+
.offset(y: effectiveOffset)
|
|
107
|
+
}
|
|
108
|
+
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
109
|
+
.ignoresSafeArea()
|
|
110
|
+
.onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillChangeFrameNotification)) { notification in
|
|
111
|
+
guard let endFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return }
|
|
112
|
+
withAnimation(.easeOut(duration: 0.25)) {
|
|
113
|
+
keyboardHeight = UIScreen.main.bounds.height - endFrame.origin.y
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
.onAppear {
|
|
117
|
+
guard !didAppear else { return }
|
|
118
|
+
didAppear = true
|
|
119
|
+
ScreenTracker.trackPopupDisplayed(
|
|
120
|
+
maxApi: appEnv.maxApi,
|
|
121
|
+
context: appEnv.applicationContext,
|
|
122
|
+
parentScreenName: ScreenTracker.getLastScreenName()
|
|
123
|
+
)
|
|
124
|
+
navigator.registerOverplayDismiss { close() }
|
|
125
|
+
open()
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private var sheet: some View {
|
|
130
|
+
VStack(spacing: 0) {
|
|
131
|
+
// Drag handle + header (the draggable region).
|
|
132
|
+
VStack(spacing: 0) {
|
|
133
|
+
Spacer().frame(height: 8)
|
|
134
|
+
Capsule()
|
|
135
|
+
.fill(Colors.black06)
|
|
136
|
+
.frame(width: 40, height: 4)
|
|
137
|
+
Spacer().frame(height: 4)
|
|
138
|
+
headerView
|
|
139
|
+
.frame(height: 56)
|
|
140
|
+
.padding(.horizontal, Spacing.M)
|
|
141
|
+
}
|
|
142
|
+
.frame(maxWidth: .infinity)
|
|
143
|
+
.frame(height: 72)
|
|
144
|
+
.contentShape(Rectangle())
|
|
145
|
+
.gesture(dragGesture)
|
|
146
|
+
|
|
147
|
+
Rectangle()
|
|
148
|
+
.fill(theme.colors.border.default)
|
|
149
|
+
.frame(height: 1)
|
|
150
|
+
|
|
151
|
+
content()
|
|
152
|
+
|
|
153
|
+
backgroundColor
|
|
154
|
+
.frame(height: effectiveBottomInset)
|
|
155
|
+
}
|
|
156
|
+
.frame(maxWidth: .infinity)
|
|
157
|
+
.frame(alignment: .bottom)
|
|
158
|
+
.background(
|
|
159
|
+
GeometryReader { geo in
|
|
160
|
+
Color.clear.onAppear { sheetHeight = geo.size.height }
|
|
161
|
+
}
|
|
162
|
+
)
|
|
163
|
+
.background(backgroundColor)
|
|
164
|
+
.clipShape(RoundedCorner(radius: Radius.M, corners: [.topLeft, .topRight]))
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
@ViewBuilder
|
|
168
|
+
private var headerView: some View {
|
|
169
|
+
switch header.style {
|
|
170
|
+
case .title(let text):
|
|
171
|
+
HStack(spacing: 0) {
|
|
172
|
+
Color.clear.frame(width: 24, height: 24)
|
|
173
|
+
MomoText(text, typography: .headerDefaultBold, color: Colors.black17)
|
|
174
|
+
.frame(maxWidth: .infinity, alignment: .center)
|
|
175
|
+
Icon(source: "navigation_close", size: 24, color: Colors.black17)
|
|
176
|
+
.frame(width: 24, height: 24)
|
|
177
|
+
.contentShape(Rectangle())
|
|
178
|
+
.onTapGesture { close() }
|
|
179
|
+
}
|
|
180
|
+
case .custom(let builder):
|
|
181
|
+
builder()
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// MARK: - Drag handling (mirrors Compose's detectDragGestures)
|
|
186
|
+
|
|
187
|
+
private var dragGesture: some Gesture {
|
|
188
|
+
// `.global` (not the default `.local`) so the gesture tracks against the
|
|
189
|
+
// window rather than the handle's own view, which is itself repositioned
|
|
190
|
+
// every frame by this same gesture's output (`sheet.offset(y: effectiveOffset)`
|
|
191
|
+
// in `body`) — the local coordinate space chasing its own moving reference
|
|
192
|
+
// frame is a plausible source of a continuous drag-time jitter.
|
|
193
|
+
DragGesture(coordinateSpace: .global)
|
|
194
|
+
.onChanged { _ in
|
|
195
|
+
// Touching the handle should immediately settle `sheetOffset` to its
|
|
196
|
+
// resting value (0) with no animation, so a drag that starts while the
|
|
197
|
+
// open animation — or the keyboard-driven resize, which shifts this
|
|
198
|
+
// same bottom-anchored top edge — is still in flight doesn't fight a
|
|
199
|
+
// moving target. Without this, the live drag value chases an animated
|
|
200
|
+
// one, producing a couple of corrective jumps before it settles.
|
|
201
|
+
if sheetOffset != 0 {
|
|
202
|
+
sheetOffset = 0
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
.updating($dragTranslation) { value, state, _ in
|
|
206
|
+
state = max(value.translation.height, 0)
|
|
207
|
+
}
|
|
208
|
+
.onEnded { value in
|
|
209
|
+
let finalOffset = max(value.translation.height, 0)
|
|
210
|
+
// Capture into persisted state BEFORE @GestureState resets to 0,
|
|
211
|
+
// avoiding a one-frame snap back to rest before close()'s own
|
|
212
|
+
// slide-out animation starts (mirrors PopupView's `lastDragPosition`).
|
|
213
|
+
sheetOffset = finalOffset
|
|
214
|
+
if finalOffset > closeThreshold {
|
|
215
|
+
close()
|
|
216
|
+
} else {
|
|
217
|
+
withAnimation(.spring(response: 0.3, dampingFraction: 0.9)) {
|
|
218
|
+
sheetOffset = 0
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// MARK: - Open / close animations
|
|
225
|
+
|
|
226
|
+
private func open() {
|
|
227
|
+
DispatchQueue.main.async {
|
|
228
|
+
withAnimation(.timingCurve(0.05, 0.7, 0.1, 1, duration: 0.1)) { backgroundAlpha = maxDynamicAlpha }
|
|
229
|
+
withAnimation(.timingCurve(0.05, 0.7, 0.1, 1, duration: 0.35)) { sheetOffset = 0 }
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/// Mirrors Compose's `closeEvent`: slide out over 200ms, then fade the scrim
|
|
234
|
+
/// out over 100ms only after the slide finishes, then dismiss.
|
|
235
|
+
private func close() {
|
|
236
|
+
withAnimation(.timingCurve(0.3, 0, 0.8, 0.15, duration: 0.2)) {
|
|
237
|
+
sheetOffset = max(sheetHeight, screenHeight)
|
|
238
|
+
}
|
|
239
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
|
240
|
+
withAnimation(.timingCurve(0.3, 0, 0.8, 0.15, duration: 0.1)) {
|
|
241
|
+
backgroundAlpha = 0
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
|
245
|
+
navigator.finishOverplayDismiss()
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
@available(iOS 16.0, *)
|
|
251
|
+
private func safeAreaBottomInset() -> CGFloat {
|
|
252
|
+
let bottomInset = UIApplication.shared.connectedScenes
|
|
253
|
+
.compactMap { ($0 as? UIWindowScene)?.keyWindow?.safeAreaInsets.bottom }
|
|
254
|
+
.first ?? 0
|
|
255
|
+
return min(bottomInset, 21)
|
|
256
|
+
}
|