@momo-kits/native-kits 0.167.1-duo.2-debug → 0.167.1-duo.20-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 +36 -8
- package/README.md +2 -2
- package/compose/build.gradle.kts +15 -1
- package/compose/build.gradle.kts.backup +14 -0
- package/compose/compose.podspec +1 -1
- package/compose/src/androidMain/kotlin/vn/momo/kits/platform/Platform.android.kt +2 -0
- package/compose/src/androidMain/kotlin/vn/momo/kits/platform/ReservedRegions.android.kt +6 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/application/LiteScreen.kt +168 -52
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Badge.kt +20 -21
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Button.kt +2 -1
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Carousel.kt +4 -2
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Chip.kt +1 -1
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Icon.kt +24 -14
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Image.kt +1 -1
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Information.kt +1 -1
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Input.kt +9 -6
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputMoney.kt +11 -12
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputOTP.kt +4 -9
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputPhoneNumber.kt +2 -2
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputSearch.kt +10 -9
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputTextArea.kt +6 -6
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/PopupNotify.kt +53 -26
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/PopupPromotion.kt +12 -2
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/ProgressInfo.kt +7 -9
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Radio.kt +1 -1
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Slider.kt +13 -6
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Swipe.kt +13 -5
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Switch.kt +3 -2
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/TabView.kt +14 -6
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Tag.kt +19 -21
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Text.kt +2 -2
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Tooltip.kt +5 -2
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/DateTimePickerUtils.kt +3 -1
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/WheelPicker.kt +7 -15
- package/compose/src/commonMain/kotlin/vn/momo/kits/const/HostFont.kt +4 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/const/Theme.kt +10 -4
- package/compose/src/commonMain/kotlin/vn/momo/kits/const/Typography.kt +3 -17
- package/compose/src/commonMain/kotlin/vn/momo/kits/layout/ScreenLayout.kt +173 -21
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/BottomSheet.kt +43 -4
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/ModalScreen.kt +8 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigation.kt +10 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/StackScreen.kt +153 -48
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTab.kt +114 -71
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTabBar.kt +8 -9
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/DuoBarCompression.kt +98 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/DuoTabBar.kt +312 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/Header.kt +84 -18
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderBackground.kt +33 -13
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderJourney.kt +2 -1
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderRight.kt +51 -19
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderTitle.kt +4 -2
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderUser.kt +1 -1
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/SideBar.kt +46 -23
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/SnackBar.kt +2 -1
- package/compose/src/commonMain/kotlin/vn/momo/kits/platform/Platform.kt +3 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/platform/ReservedRegions.kt +13 -0
- package/compose/src/iosMain/kotlin/vn/momo/kits/const/HostFont.ios.kt +38 -0
- package/compose/src/iosMain/kotlin/vn/momo/kits/platform/Platform.ios.kt +4 -0
- package/compose/src/iosMain/kotlin/vn/momo/kits/platform/ReservedRegions.ios.kt +94 -0
- package/gradle.properties +1 -1
- package/ios/Application/ApplicationEnvironment.swift +17 -0
- package/ios/Application/MaxApi.swift +12 -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 +0 -1
- 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 +160 -147
- package/ios/Input/InputPhoneNumber.swift +12 -9
- package/ios/Input/InputSearch.swift +102 -75
- package/ios/Input/InputTextArea.swift +128 -145
- package/ios/InputDropDown/InputDropDown.swift +10 -7
- package/ios/InputMoney/InputMoney.swift +33 -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 +380 -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 +128 -9
- package/ios/Navigation/BottomTab/BottomTab.swift +185 -0
- package/ios/{Application/Navigation → Navigation}/BottomTab/BottomTabBar.swift +15 -7
- package/ios/Navigation/BottomTab/DuoBarCompression.swift +105 -0
- package/ios/Navigation/BottomTab/DuoTabBar.swift +304 -0
- package/ios/{Application → Navigation/Component}/FloatingButton.swift +14 -3
- package/ios/Navigation/Component/Header.swift +531 -0
- package/ios/Navigation/Component/HeaderBackground.swift +133 -0
- package/ios/Navigation/Component/HeaderJourney.swift +73 -0
- package/ios/Navigation/Component/HeaderLocation.swift +48 -0
- package/ios/Navigation/Component/HeaderRight.swift +377 -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 +5 -6
- package/ios/Navigation/HostBackGestureBridge.swift +78 -0
- package/ios/{Application/Navigation/Overplay → Navigation}/ModalScreen.swift +5 -2
- package/ios/Navigation/Navigation.swift +204 -0
- package/ios/Navigation/NavigationContainer.swift +194 -0
- package/ios/{Application/Navigation → Navigation}/Navigator.swift +177 -91
- package/ios/Navigation/StackScreen.swift +820 -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 -136
- package/ios/Popup/PopupInput.swift +10 -13
- package/ios/Popup/PopupLayout.swift +140 -0
- package/ios/Popup/PopupNotify.swift +56 -73
- 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 +6 -1
- 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 +106 -54
- package/ios/Title/Title.swift +6 -17
- package/ios/Tooltip/Tooltip.swift +29 -14
- package/ios/Typography/Text.swift +61 -19
- package/ios/Uploader/Uploader.swift +3 -1
- package/ios/native-kits.podspec +2 -1
- package/ios-demo/MoMoUIKitsDemo.podspec +1 -1
- 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 +1 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/IPhoneDuoDemo.swift +173 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/NavigationDemo.swift +77 -1
- package/package.json +1 -1
- package/scripts/gen-ios-icons.mjs +36 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/application/AnimationSearchInput.kt +0 -52
- package/compose/src/commonMain/kotlin/vn/momo/kits/application/FloatingButton.kt +0 -183
- package/compose/src/commonMain/kotlin/vn/momo/kits/application/Header.kt +0 -207
- package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderAnimated.kt +0 -39
- package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderBackground.kt +0 -86
- package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderDefault.kt +0 -76
- package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderExtended.kt +0 -76
- package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderRight.kt +0 -293
- package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderTitle.kt +0 -33
- package/compose/src/commonMain/kotlin/vn/momo/kits/application/NavigationContainer.kt +0 -110
- package/compose/src/commonMain/kotlin/vn/momo/kits/application/Screen.kt +0 -378
- package/compose/src/commonMain/kotlin/vn/momo/kits/application/useHeaderSearchAnimation.kt +0 -69
- package/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/{Application/Navigation → Navigation}/BottomTab/CurvedContainer.swift +0 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
//
|
|
2
|
+
// NavigationContainer.swift
|
|
3
|
+
// MoMoUIKits
|
|
4
|
+
//
|
|
5
|
+
// SwiftUI mirror of Compose's `navigation/NavigationContainer.kt`: hosts the kit stack
|
|
6
|
+
// (`StackHost`, the NavHost counterpart), injects Navigator, theme, localize and font scale, and
|
|
7
|
+
// renders every registered screen inside `StackScreen` chrome built from its options.
|
|
8
|
+
//
|
|
9
|
+
|
|
10
|
+
import SwiftUI
|
|
11
|
+
|
|
12
|
+
public struct NavigationContainer<Initial: View>: View {
|
|
13
|
+
@StateObject private var navigator: Navigator
|
|
14
|
+
@StateObject private var applicationEnvironment: ApplicationEnvironment
|
|
15
|
+
@StateObject private var localize: Localize
|
|
16
|
+
|
|
17
|
+
@Environment(\.appTheme) private var parentTheme
|
|
18
|
+
|
|
19
|
+
private let setNavigator: ((Navigator) -> Void)?
|
|
20
|
+
private let composeApi: KitComposeApi?
|
|
21
|
+
private let maxApi: MaxApi?
|
|
22
|
+
private let initialTheme: Theme?
|
|
23
|
+
|
|
24
|
+
/// `initialTheme` nil keeps the theme the host already put in the environment.
|
|
25
|
+
public init(
|
|
26
|
+
initialScreenName: String = "",
|
|
27
|
+
@ViewBuilder initialScreen: @escaping () -> Initial,
|
|
28
|
+
options: NavigationOptions? = nil,
|
|
29
|
+
initialTheme: Theme? = nil,
|
|
30
|
+
applicationContext: MiniAppContext? = nil,
|
|
31
|
+
composeApi: KitComposeApi? = nil,
|
|
32
|
+
config: KitConfig? = nil,
|
|
33
|
+
maxApi: MaxApi? = nil,
|
|
34
|
+
localize: Localize? = nil,
|
|
35
|
+
setNavigator: ((Navigator) -> Void)? = nil
|
|
36
|
+
) {
|
|
37
|
+
let nav = Navigator(
|
|
38
|
+
initialScreenName: initialScreenName.isEmpty ? (options?.screenName ?? "") : initialScreenName,
|
|
39
|
+
initialContent: { AnyView(initialScreen()) },
|
|
40
|
+
options: options,
|
|
41
|
+
composeApi: composeApi
|
|
42
|
+
)
|
|
43
|
+
nav.maxApi = maxApi
|
|
44
|
+
_navigator = StateObject(wrappedValue: nav)
|
|
45
|
+
_applicationEnvironment = StateObject(
|
|
46
|
+
wrappedValue: ApplicationEnvironment(
|
|
47
|
+
applicationContext: applicationContext,
|
|
48
|
+
composeApi: composeApi,
|
|
49
|
+
config: config,
|
|
50
|
+
maxApi: maxApi
|
|
51
|
+
)
|
|
52
|
+
)
|
|
53
|
+
// AI-GENERATED START: default to the app-language-observing shared Localize
|
|
54
|
+
_localize = StateObject(wrappedValue: localize ?? Localize.shared)
|
|
55
|
+
// AI-GENERATED END: default to the app-language-observing shared Localize
|
|
56
|
+
self.composeApi = composeApi
|
|
57
|
+
self.maxApi = maxApi
|
|
58
|
+
self.initialTheme = initialTheme
|
|
59
|
+
self.setNavigator = setNavigator
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/// RN parity: the theme's own header image wins; config `headerBar` is only the fallback.
|
|
63
|
+
private var resolvedTheme: Theme {
|
|
64
|
+
let theme = initialTheme ?? parentTheme
|
|
65
|
+
guard !theme.assets.isHeaderImage,
|
|
66
|
+
let headerBar = applicationEnvironment.config?.headerBar, !headerBar.isEmpty else {
|
|
67
|
+
return theme
|
|
68
|
+
}
|
|
69
|
+
return theme.copy(assets: ThemeAssets(headerBackground: headerBar))
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
public var body: some View {
|
|
73
|
+
StackHost(
|
|
74
|
+
navigator: navigator,
|
|
75
|
+
entries: navigator.entries,
|
|
76
|
+
screen: { [navigator] entry in
|
|
77
|
+
guard let screen = navigator.registry.getScreen(id: entry.id) else { return AnyView(EmptyView()) }
|
|
78
|
+
return AnyView(ScreenHost(screen: screen, navigator: navigator))
|
|
79
|
+
},
|
|
80
|
+
decorate: { AnyView(withKitEnvironment($0)) }
|
|
81
|
+
)
|
|
82
|
+
// edge to edge: each screen's hosting controller takes the safe area and keyboard itself
|
|
83
|
+
.ignoresSafeArea()
|
|
84
|
+
.onAppear {
|
|
85
|
+
if navigator.composeApi == nil {
|
|
86
|
+
navigator.composeApi = composeApi
|
|
87
|
+
}
|
|
88
|
+
if navigator.maxApi == nil {
|
|
89
|
+
navigator.maxApi = maxApi
|
|
90
|
+
}
|
|
91
|
+
setNavigator?(navigator)
|
|
92
|
+
// Idempotent, so a re-appear won't double-count.
|
|
93
|
+
HostBackGestureBridge.shared.attach(navigator)
|
|
94
|
+
// AI-GENERATED START: demote the MaxApi language pull to a seed of last resort
|
|
95
|
+
// Only a seed: a Localize with a language source already tracks the host in both
|
|
96
|
+
// directions, so asking maxApi would just race it. `changeLanguage` normalizes the
|
|
97
|
+
// raw response and hops to main itself.
|
|
98
|
+
guard !localize.observesAppLanguage else { return }
|
|
99
|
+
maxApi?.getLanguage { data in
|
|
100
|
+
localize.changeLanguage(data?["response"] as? String)
|
|
101
|
+
}
|
|
102
|
+
// AI-GENERATED END: demote the MaxApi language pull to a seed of last resort
|
|
103
|
+
}
|
|
104
|
+
.onDisappear {
|
|
105
|
+
HostBackGestureBridge.shared.detach(navigator)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private func withKitEnvironment<Content: View>(_ content: Content) -> some View {
|
|
110
|
+
content
|
|
111
|
+
.environmentObject(navigator)
|
|
112
|
+
.environmentObject(applicationEnvironment)
|
|
113
|
+
.environment(\.applicationEnvironment, applicationEnvironment)
|
|
114
|
+
.environment(\.appTheme, resolvedTheme)
|
|
115
|
+
.momoLocalize(localize)
|
|
116
|
+
.momoFontScale(context: applicationEnvironment.applicationContext)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// MARK: - ScreenHost
|
|
121
|
+
|
|
122
|
+
private struct ScreenHost: View {
|
|
123
|
+
let screen: DynamicScreen
|
|
124
|
+
let navigator: Navigator
|
|
125
|
+
@StateObject private var navigation: Navigation
|
|
126
|
+
|
|
127
|
+
init(screen: DynamicScreen, navigator: Navigator) {
|
|
128
|
+
self.screen = screen
|
|
129
|
+
self.navigator = navigator
|
|
130
|
+
_navigation = StateObject(
|
|
131
|
+
wrappedValue: Navigation(id: screen.id, options: screen.options, registry: navigator.registry)
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
var body: some View {
|
|
136
|
+
OptionsStackScreen(
|
|
137
|
+
options: navigation.options,
|
|
138
|
+
screenName: screen.name,
|
|
139
|
+
goBack: { navigator.onBackSafe() },
|
|
140
|
+
content: screen.content
|
|
141
|
+
)
|
|
142
|
+
// Compose draws the overplay inside the screen it was shown over, under its LocalNavigation.
|
|
143
|
+
.overlay { OverplayLayer(screenId: screen.id) }
|
|
144
|
+
.environmentObject(navigation)
|
|
145
|
+
.momoScreenLayout()
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private struct OverplayLayer: View {
|
|
150
|
+
let screenId: Int
|
|
151
|
+
@EnvironmentObject private var navigator: Navigator
|
|
152
|
+
|
|
153
|
+
var body: some View {
|
|
154
|
+
if let item = navigator.overplay, item.screenId == screenId {
|
|
155
|
+
OverplayHost(item: item)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// MARK: - Overplay host
|
|
161
|
+
|
|
162
|
+
private struct OverplayHost: View {
|
|
163
|
+
let item: OverplayItem
|
|
164
|
+
@EnvironmentObject var navigator: Navigator
|
|
165
|
+
|
|
166
|
+
var body: some View {
|
|
167
|
+
// Each component self-animates on appear and drives its own exit through
|
|
168
|
+
// the Navigator before the item is removed (mirrors Compose's overplay).
|
|
169
|
+
switch item.type {
|
|
170
|
+
case .modal:
|
|
171
|
+
ModalScreen(barrierDismissible: item.barrierDismissible) {
|
|
172
|
+
item.content()
|
|
173
|
+
}
|
|
174
|
+
.id(item.id)
|
|
175
|
+
case .bottomSheet:
|
|
176
|
+
BottomSheetView(
|
|
177
|
+
header: item.header ?? .title(),
|
|
178
|
+
isSurface: item.isSurface,
|
|
179
|
+
barrierDismissible: item.barrierDismissible,
|
|
180
|
+
draggable: item.draggable,
|
|
181
|
+
useBottomInset: item.useBottomInset,
|
|
182
|
+
footer: item.footer
|
|
183
|
+
) {
|
|
184
|
+
item.content()
|
|
185
|
+
}
|
|
186
|
+
.id(item.id)
|
|
187
|
+
case .snackBar:
|
|
188
|
+
if let snackBar = item.snackBar {
|
|
189
|
+
SnackBarView(snackBar: snackBar, bottomInset: navigator.snackBarBottomInset())
|
|
190
|
+
.id(item.id)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
// Navigator.swift
|
|
3
3
|
// MoMoUIKits
|
|
4
4
|
//
|
|
5
|
-
// SwiftUI mirror of Compose's Navigator
|
|
6
|
-
// Screens are registered by Int id; the
|
|
7
|
-
//
|
|
5
|
+
// SwiftUI mirror of Compose's `navigation/Navigator.kt`: Navigator, DynamicScreenRegistry and
|
|
6
|
+
// the overplay model. Screens are registered by Int id; the stack paths store routes by id and
|
|
7
|
+
// content is looked up from the registry on render.
|
|
8
8
|
//
|
|
9
9
|
|
|
10
10
|
import SwiftUI
|
|
@@ -31,6 +31,7 @@ public struct DynamicScreen {
|
|
|
31
31
|
|
|
32
32
|
public final class DynamicScreenRegistry {
|
|
33
33
|
private var screens: [Int: DynamicScreen] = [:]
|
|
34
|
+
private var navigations: [Int: WeakNavigation] = [:]
|
|
34
35
|
private var idCounter: Int = 1
|
|
35
36
|
|
|
36
37
|
public init() {}
|
|
@@ -49,12 +50,20 @@ public final class DynamicScreenRegistry {
|
|
|
49
50
|
|
|
50
51
|
public func unregister(id: Int) {
|
|
51
52
|
screens.removeValue(forKey: id)
|
|
53
|
+
navigations.removeValue(forKey: id)
|
|
52
54
|
}
|
|
53
55
|
|
|
54
56
|
public func unregisterAll() {
|
|
55
57
|
screens.removeAll()
|
|
58
|
+
navigations.removeAll()
|
|
56
59
|
}
|
|
57
60
|
|
|
61
|
+
func bind(_ navigation: Navigation) {
|
|
62
|
+
navigations[navigation.id] = WeakNavigation(value: navigation)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
func navigation(id: Int) -> Navigation? { navigations[id]?.value }
|
|
66
|
+
|
|
58
67
|
public func getScreen(id: Int) -> DynamicScreen? { screens[id] }
|
|
59
68
|
|
|
60
69
|
public func latest() -> DynamicScreen? {
|
|
@@ -66,6 +75,10 @@ public final class DynamicScreenRegistry {
|
|
|
66
75
|
}
|
|
67
76
|
}
|
|
68
77
|
|
|
78
|
+
private struct WeakNavigation {
|
|
79
|
+
weak var value: Navigation?
|
|
80
|
+
}
|
|
81
|
+
|
|
69
82
|
// MARK: - Overplay
|
|
70
83
|
|
|
71
84
|
public enum OverplayType {
|
|
@@ -81,20 +94,34 @@ public struct OverplayItem: Identifiable {
|
|
|
81
94
|
// Bottom-sheet params (mirrors Compose's OverplayComponentParams.BottomSheet).
|
|
82
95
|
public var isSurface: Bool = false
|
|
83
96
|
public var header: BottomSheetHeader? = nil
|
|
97
|
+
public var draggable: Bool = true
|
|
98
|
+
public var useBottomInset: Bool = true
|
|
99
|
+
public var footer: (() -> AnyView)? = nil
|
|
84
100
|
// Snack-bar payload (mirrors Compose's SnackBar value).
|
|
85
101
|
public var snackBar: SnackBar? = nil
|
|
102
|
+
/// The screen it was shown over (Compose's overplay root id): it renders inside that screen,
|
|
103
|
+
/// so a later push slides over it instead of re-mounting it on the new top.
|
|
104
|
+
public internal(set) var screenId: Int? = nil
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// MARK: - Stack
|
|
108
|
+
|
|
109
|
+
/// One back-stack entry, root first. A presented screen is an entry like any other (Compose's
|
|
110
|
+
/// `DynamicDialogRoute` in the same NavHost): it rises instead of sliding in, and later pushes
|
|
111
|
+
/// stack on top of it.
|
|
112
|
+
struct StackEntry: Hashable, Identifiable {
|
|
113
|
+
let id: Int
|
|
114
|
+
let isDialog: Bool
|
|
86
115
|
}
|
|
87
116
|
|
|
88
117
|
// MARK: - Navigator
|
|
89
118
|
|
|
90
|
-
@available(iOS 16.0, *)
|
|
91
119
|
public final class Navigator: ObservableObject {
|
|
92
|
-
@Published
|
|
93
|
-
// Disposal hangs off the
|
|
94
|
-
//
|
|
95
|
-
didSet { disposeScreens(droppedFrom: oldValue) }
|
|
120
|
+
@Published private(set) var entries: [StackEntry] = [] {
|
|
121
|
+
// Disposal hangs off the stack so every entry that leaves it — pop, replace, reset,
|
|
122
|
+
// dismiss — is released the same way, after its exit transition.
|
|
123
|
+
didSet { disposeScreens(droppedFrom: oldValue, keeping: entries) }
|
|
96
124
|
}
|
|
97
|
-
@Published public var presented: DynamicDialogRoute?
|
|
98
125
|
@Published public var overplay: OverplayItem?
|
|
99
126
|
|
|
100
127
|
/// Owned per-Navigator (mirrors Compose's `remember { DynamicScreenRegistry() }` +
|
|
@@ -108,8 +135,20 @@ public final class Navigator: ObservableObject {
|
|
|
108
135
|
/// actually removed via `finishOverplayDismiss`.
|
|
109
136
|
private var overplayDismissHandler: (() -> Void)?
|
|
110
137
|
|
|
111
|
-
public
|
|
138
|
+
public var rootRoute: DynamicScreenRoute { DynamicScreenRoute(id: entries[0].id) }
|
|
112
139
|
public var composeApi: KitComposeApi?
|
|
140
|
+
/// Host dismiss fallback when there is no `composeApi` (Compose's `maxApi.dismiss`).
|
|
141
|
+
public var maxApi: MaxApi?
|
|
142
|
+
|
|
143
|
+
/// Set by a bottom tab off its first tab: back on the screen hosting the tabs returns to the
|
|
144
|
+
/// first tab (Compose `tabBackHandler`). Keyed by that screen, so a screen pushed or presented
|
|
145
|
+
/// over the tabs still pops normally.
|
|
146
|
+
var tabBackHandler: (screenId: Int, handler: () -> Void)?
|
|
147
|
+
|
|
148
|
+
/// The container's stack view, which plays (and lets the host gesture scrub) the transitions.
|
|
149
|
+
weak var stackController: StackController?
|
|
150
|
+
private var isSeekingBack = false
|
|
151
|
+
private var swipeHaptic = SwipeBackHaptic()
|
|
113
152
|
|
|
114
153
|
public init(
|
|
115
154
|
initialScreenName: String = "",
|
|
@@ -118,22 +157,18 @@ public final class Navigator: ObservableObject {
|
|
|
118
157
|
composeApi: KitComposeApi? = nil
|
|
119
158
|
) {
|
|
120
159
|
self.composeApi = composeApi
|
|
121
|
-
|
|
160
|
+
let root = registry.register(
|
|
122
161
|
screenName: initialScreenName,
|
|
123
162
|
content: initialContent,
|
|
124
163
|
options: options
|
|
125
164
|
)
|
|
165
|
+
entries = [StackEntry(id: root.id, isDialog: false)]
|
|
126
166
|
}
|
|
127
167
|
|
|
128
168
|
// MARK: Stack
|
|
129
169
|
|
|
130
170
|
public func push<V: View>(@ViewBuilder _ content: @escaping () -> V, options: NavigationOptions? = nil) {
|
|
131
|
-
|
|
132
|
-
screenName: options?.screenName ?? "",
|
|
133
|
-
content: { AnyView(content()) },
|
|
134
|
-
options: options
|
|
135
|
-
)
|
|
136
|
-
path.append(route)
|
|
171
|
+
push(screenName: options?.screenName ?? "", content, options: options)
|
|
137
172
|
}
|
|
138
173
|
|
|
139
174
|
public func push<V: View>(
|
|
@@ -141,40 +176,26 @@ public final class Navigator: ObservableObject {
|
|
|
141
176
|
@ViewBuilder _ content: @escaping () -> V,
|
|
142
177
|
options: NavigationOptions? = nil
|
|
143
178
|
) {
|
|
144
|
-
|
|
145
|
-
screenName: screenName,
|
|
146
|
-
content: { AnyView(content()) },
|
|
147
|
-
options: options
|
|
148
|
-
)
|
|
149
|
-
path.append(route)
|
|
179
|
+
entries.append(register(screenName, content, options, isDialog: false))
|
|
150
180
|
}
|
|
151
181
|
|
|
152
182
|
public func replace<V: View>(@ViewBuilder _ content: @escaping () -> V, options: NavigationOptions? = nil) {
|
|
153
|
-
|
|
154
|
-
path.removeLast()
|
|
155
|
-
push(content, options: options)
|
|
183
|
+
replace(screenName: options?.screenName ?? "", content, options: options)
|
|
156
184
|
}
|
|
157
185
|
|
|
186
|
+
/// Compose pops the top entry (a presented one included) and navigates, so the replacement
|
|
187
|
+
/// always lands as a pushed screen; on the root it becomes the new root.
|
|
158
188
|
public func replace<V: View>(
|
|
159
189
|
screenName: String,
|
|
160
190
|
@ViewBuilder _ content: @escaping () -> V,
|
|
161
191
|
options: NavigationOptions? = nil
|
|
162
192
|
) {
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
push(screenName: screenName, content, options: options)
|
|
193
|
+
let entry = register(screenName, content, options, isDialog: false)
|
|
194
|
+
entries = Array(entries.dropLast()) + [entry]
|
|
166
195
|
}
|
|
167
196
|
|
|
168
197
|
public func reset<V: View>(@ViewBuilder _ content: @escaping () -> V, options: NavigationOptions? = nil) {
|
|
169
|
-
|
|
170
|
-
path.removeAll()
|
|
171
|
-
let route = registry.register(
|
|
172
|
-
screenName: options?.screenName ?? "",
|
|
173
|
-
content: { AnyView(content()) },
|
|
174
|
-
options: options
|
|
175
|
-
)
|
|
176
|
-
rootRoute = route
|
|
177
|
-
disposeScreens([previousRoot])
|
|
198
|
+
reset(screenName: options?.screenName ?? "", content, options: options)
|
|
178
199
|
}
|
|
179
200
|
|
|
180
201
|
public func reset<V: View>(
|
|
@@ -182,15 +203,21 @@ public final class Navigator: ObservableObject {
|
|
|
182
203
|
@ViewBuilder _ content: @escaping () -> V,
|
|
183
204
|
options: NavigationOptions? = nil
|
|
184
205
|
) {
|
|
185
|
-
|
|
186
|
-
|
|
206
|
+
entries = [register(screenName, content, options, isDialog: false)]
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private func register<V: View>(
|
|
210
|
+
_ screenName: String,
|
|
211
|
+
_ content: @escaping () -> V,
|
|
212
|
+
_ options: NavigationOptions?,
|
|
213
|
+
isDialog: Bool
|
|
214
|
+
) -> StackEntry {
|
|
187
215
|
let route = registry.register(
|
|
188
216
|
screenName: screenName,
|
|
189
217
|
content: { AnyView(content()) },
|
|
190
218
|
options: options
|
|
191
219
|
)
|
|
192
|
-
|
|
193
|
-
disposeScreens([previousRoot])
|
|
220
|
+
return StackEntry(id: route.id, isDialog: isDialog)
|
|
194
221
|
}
|
|
195
222
|
|
|
196
223
|
public func pop(_ count: Int = 1, callback: (() -> Void)? = nil) {
|
|
@@ -236,43 +263,57 @@ public final class Navigator: ObservableObject {
|
|
|
236
263
|
}
|
|
237
264
|
}
|
|
238
265
|
|
|
239
|
-
/// Pops the
|
|
240
|
-
/// a host dismiss) when there is nothing left to pop.
|
|
266
|
+
/// Pops the top entry. Returns `false` (and requests a host dismiss) on the root.
|
|
241
267
|
@discardableResult
|
|
242
268
|
private func popScreen() -> Bool {
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
presented = nil
|
|
246
|
-
if let id = dismissedId { disposeScreens([id]) }
|
|
247
|
-
return true
|
|
248
|
-
} else if !path.isEmpty {
|
|
249
|
-
path.removeLast()
|
|
250
|
-
return true
|
|
251
|
-
} else {
|
|
252
|
-
composeApi?.requestCallback(funcName: "dismiss", params: nil, onResponse: nil)
|
|
269
|
+
guard canPop() else {
|
|
270
|
+
requestHostDismiss()
|
|
253
271
|
return false
|
|
254
272
|
}
|
|
273
|
+
entries.removeLast()
|
|
274
|
+
return true
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
private func requestHostDismiss() {
|
|
278
|
+
if let composeApi {
|
|
279
|
+
composeApi.requestCallback(funcName: "dismiss", params: nil, onResponse: nil)
|
|
280
|
+
} else {
|
|
281
|
+
maxApi?.dismiss(completion: nil)
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/// Whether there is a screen to go back to (Compose's `canPop`); at the root a back
|
|
286
|
+
/// dismisses the mini app instead.
|
|
287
|
+
public func canPop() -> Bool {
|
|
288
|
+
entries.count > 1
|
|
255
289
|
}
|
|
256
290
|
|
|
291
|
+
/// Back to the root of the active flow: the topmost presented screen while one is up,
|
|
292
|
+
/// the stack root otherwise.
|
|
257
293
|
public func popToRoot() {
|
|
258
|
-
|
|
294
|
+
let root = entries.lastIndex(where: \.isDialog) ?? 0
|
|
295
|
+
entries = Array(entries[...root])
|
|
259
296
|
}
|
|
260
297
|
|
|
261
|
-
/// The entry a back action applies to —
|
|
262
|
-
/// (`navController.currentBackStackEntry`)
|
|
263
|
-
/// the stack, otherwise it is the last pushed screen, otherwise the root.
|
|
298
|
+
/// The entry a back action applies to — Compose's `currentScreen()`
|
|
299
|
+
/// (`navController.currentBackStackEntry`).
|
|
264
300
|
private func currentScreen() -> DynamicScreen? {
|
|
265
|
-
registry.getScreen(id:
|
|
301
|
+
entries.last.flatMap { registry.getScreen(id: $0.id) }
|
|
266
302
|
}
|
|
267
303
|
|
|
268
304
|
public func onBackSafe(callback: (() -> Void)? = nil) {
|
|
269
305
|
let latest = currentScreen()
|
|
306
|
+
if let tabBackHandler, tabBackHandler.screenId == -1 || tabBackHandler.screenId == latest?.id {
|
|
307
|
+
tabBackHandler.handler()
|
|
308
|
+
return
|
|
309
|
+
}
|
|
270
310
|
if let onBack = latest?.options?.onBackHandler {
|
|
271
311
|
onBack()
|
|
272
312
|
return
|
|
273
313
|
}
|
|
274
314
|
if let preventBack = latest?.options?.headerBackProps?.preventBack {
|
|
275
|
-
|
|
315
|
+
// stored in `overplay`, so a strong self would cycle until the popup closes
|
|
316
|
+
showModal { [weak self] in
|
|
276
317
|
PopupNotify(
|
|
277
318
|
props: PopupNotifyProps(
|
|
278
319
|
image: preventBack.image,
|
|
@@ -281,7 +322,7 @@ public final class Navigator: ObservableObject {
|
|
|
281
322
|
error: preventBack.error,
|
|
282
323
|
primary: PopupAction(
|
|
283
324
|
title: preventBack.primary?.title ?? "",
|
|
284
|
-
onPress: {
|
|
325
|
+
onPress: {
|
|
285
326
|
preventBack.primary?.onPress?()
|
|
286
327
|
// Close the popup, then leave the screen (matches Compose's pop(2)).
|
|
287
328
|
self?.pop(2, callback: callback)
|
|
@@ -290,14 +331,14 @@ public final class Navigator: ObservableObject {
|
|
|
290
331
|
secondary: preventBack.secondary.map { secondary in
|
|
291
332
|
PopupAction(
|
|
292
333
|
title: secondary.title,
|
|
293
|
-
onPress: {
|
|
334
|
+
onPress: {
|
|
294
335
|
secondary.onPress?()
|
|
295
336
|
self?.hideOverplay()
|
|
296
337
|
}
|
|
297
338
|
)
|
|
298
339
|
},
|
|
299
340
|
buttonDirection: preventBack.buttonDirection,
|
|
300
|
-
onIconClose: {
|
|
341
|
+
onIconClose: {
|
|
301
342
|
preventBack.onIconClose?()
|
|
302
343
|
self?.hideOverplay()
|
|
303
344
|
},
|
|
@@ -313,12 +354,7 @@ public final class Navigator: ObservableObject {
|
|
|
313
354
|
// MARK: Modal-style screen (vertical slide)
|
|
314
355
|
|
|
315
356
|
public func present<V: View>(@ViewBuilder _ content: @escaping () -> V, options: NavigationOptions? = nil) {
|
|
316
|
-
|
|
317
|
-
screenName: options?.screenName ?? "",
|
|
318
|
-
content: { AnyView(content()) },
|
|
319
|
-
options: options
|
|
320
|
-
)
|
|
321
|
-
presented = DynamicDialogRoute(id: route.id)
|
|
357
|
+
present(screenName: options?.screenName ?? "", content, options: options)
|
|
322
358
|
}
|
|
323
359
|
|
|
324
360
|
public func present<V: View>(
|
|
@@ -326,19 +362,55 @@ public final class Navigator: ObservableObject {
|
|
|
326
362
|
@ViewBuilder _ content: @escaping () -> V,
|
|
327
363
|
options: NavigationOptions? = nil
|
|
328
364
|
) {
|
|
329
|
-
|
|
330
|
-
screenName: screenName,
|
|
331
|
-
content: { AnyView(content()) },
|
|
332
|
-
options: options
|
|
333
|
-
)
|
|
334
|
-
presented = DynamicDialogRoute(id: route.id)
|
|
365
|
+
entries.append(register(screenName, content, options, isDialog: true))
|
|
335
366
|
}
|
|
336
367
|
|
|
368
|
+
/// Closes the topmost presented screen together with everything pushed over it.
|
|
337
369
|
public func dismiss() {
|
|
338
|
-
|
|
339
|
-
|
|
370
|
+
guard let presented = entries.lastIndex(where: \.isDialog) else { return }
|
|
371
|
+
entries = Array(entries[..<presented])
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// MARK: Host back gesture (Compose's HostBackTarget)
|
|
375
|
+
|
|
376
|
+
/// Only a plain pop is scrubbed. A back the screen handles itself — tab back, custom
|
|
377
|
+
/// handler, prevent-back popup, an open overplay — or one closing a presented screen
|
|
378
|
+
/// waits for the release and goes through `onBackSafe`, as RN turns the gesture off there.
|
|
379
|
+
private var canSeekBack: Bool {
|
|
380
|
+
guard canPop(), overplay == nil, let top = entries.last, !top.isDialog else { return false }
|
|
381
|
+
if let tabBackHandler, tabBackHandler.screenId == -1 || tabBackHandler.screenId == top.id {
|
|
382
|
+
return false
|
|
340
383
|
}
|
|
341
|
-
|
|
384
|
+
let options = registry.getScreen(id: top.id)?.options
|
|
385
|
+
return options?.onBackHandler == nil && options?.headerBackProps?.preventBack == nil
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
func backStarted() {
|
|
389
|
+
swipeHaptic.reset()
|
|
390
|
+
isSeekingBack = canSeekBack && stackController?.beginBackSeek() == true
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
func backProgressed(_ progress: CGFloat) {
|
|
394
|
+
guard isSeekingBack, let stackController else { return }
|
|
395
|
+
stackController.seekBack(to: progress)
|
|
396
|
+
swipeHaptic.update(travelled: progress * stackController.view.bounds.width, maxApi: maxApi)
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
func backCompleted() {
|
|
400
|
+
let wasSeeking = isSeekingBack
|
|
401
|
+
isSeekingBack = false
|
|
402
|
+
// A seek cut short by a programmatic change falls back to a regular back.
|
|
403
|
+
guard wasSeeking, stackController?.finishBackSeek() == true else {
|
|
404
|
+
onBackSafe()
|
|
405
|
+
return
|
|
406
|
+
}
|
|
407
|
+
entries.removeLast()
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
func backCancelled() {
|
|
411
|
+
guard isSeekingBack else { return }
|
|
412
|
+
isSeekingBack = false
|
|
413
|
+
stackController?.cancelBackSeek()
|
|
342
414
|
}
|
|
343
415
|
|
|
344
416
|
// MARK: Overplay
|
|
@@ -353,7 +425,8 @@ public final class Navigator: ObservableObject {
|
|
|
353
425
|
type: .modal,
|
|
354
426
|
content: { AnyView(content()) },
|
|
355
427
|
barrierDismissible: barrierDismissible,
|
|
356
|
-
onDismiss: onDismiss
|
|
428
|
+
onDismiss: onDismiss,
|
|
429
|
+
screenId: entries.last?.id
|
|
357
430
|
)
|
|
358
431
|
}
|
|
359
432
|
|
|
@@ -362,7 +435,10 @@ public final class Navigator: ObservableObject {
|
|
|
362
435
|
isSurface: Bool = false,
|
|
363
436
|
barrierDismissible: Bool = true,
|
|
364
437
|
header: BottomSheetHeader? = nil,
|
|
365
|
-
onDismiss: (() -> Void)? = nil
|
|
438
|
+
onDismiss: (() -> Void)? = nil,
|
|
439
|
+
draggable: Bool = true,
|
|
440
|
+
useBottomInset: Bool = true,
|
|
441
|
+
footer: (() -> AnyView)? = nil
|
|
366
442
|
) {
|
|
367
443
|
overplayDismissHandler = nil
|
|
368
444
|
overplay = OverplayItem(
|
|
@@ -371,7 +447,11 @@ public final class Navigator: ObservableObject {
|
|
|
371
447
|
barrierDismissible: barrierDismissible,
|
|
372
448
|
onDismiss: onDismiss,
|
|
373
449
|
isSurface: isSurface,
|
|
374
|
-
header: header
|
|
450
|
+
header: header,
|
|
451
|
+
draggable: draggable,
|
|
452
|
+
useBottomInset: useBottomInset,
|
|
453
|
+
footer: footer,
|
|
454
|
+
screenId: entries.last?.id
|
|
375
455
|
)
|
|
376
456
|
}
|
|
377
457
|
|
|
@@ -383,7 +463,8 @@ public final class Navigator: ObservableObject {
|
|
|
383
463
|
content: { AnyView(EmptyView()) },
|
|
384
464
|
barrierDismissible: false,
|
|
385
465
|
onDismiss: onDismiss,
|
|
386
|
-
snackBar: snackBar
|
|
466
|
+
snackBar: snackBar,
|
|
467
|
+
screenId: entries.last?.id
|
|
387
468
|
)
|
|
388
469
|
}
|
|
389
470
|
|
|
@@ -419,6 +500,14 @@ public final class Navigator: ObservableObject {
|
|
|
419
500
|
if overplay?.type == .snackBar { hideOverplay() }
|
|
420
501
|
}
|
|
421
502
|
|
|
503
|
+
/// Room the current screen's footer (or navigation bar) takes from the window bottom.
|
|
504
|
+
func snackBarBottomInset() -> CGFloat {
|
|
505
|
+
guard let id = currentScreen()?.id, let navigation = registry.navigation(id: id) else {
|
|
506
|
+
return NavigationFooter.navigationBarInset
|
|
507
|
+
}
|
|
508
|
+
return navigation.bottomObstruction
|
|
509
|
+
}
|
|
510
|
+
|
|
422
511
|
// MARK: Screen disposal
|
|
423
512
|
|
|
424
513
|
/// Matches the `delay(300)` Compose waits in `dismissScreen` before unregistering,
|
|
@@ -429,22 +518,19 @@ public final class Navigator: ObservableObject {
|
|
|
429
518
|
/// after the component has taken itself down.
|
|
430
519
|
private static let overplayDismissDelay: TimeInterval = 0.3
|
|
431
520
|
|
|
432
|
-
private func disposeScreens(droppedFrom previous: [
|
|
433
|
-
let
|
|
434
|
-
disposeScreens(previous.map(\.id).filter { !
|
|
521
|
+
private func disposeScreens(droppedFrom previous: [StackEntry], keeping current: [StackEntry]) {
|
|
522
|
+
let kept = Set(current.map(\.id))
|
|
523
|
+
disposeScreens(previous.map(\.id).filter { !kept.contains($0) })
|
|
435
524
|
}
|
|
436
525
|
|
|
437
526
|
/// Screens are released only once the pop transition has played out: the outgoing
|
|
438
|
-
/// view is still on screen while it animates
|
|
439
|
-
///
|
|
440
|
-
/// when the timer fires, so a route pushed back before then keeps its screen.
|
|
527
|
+
/// view is still on screen while it animates. Liveness is re-checked when the timer
|
|
528
|
+
/// fires, so an entry back on the stack by then keeps its screen.
|
|
441
529
|
private func disposeScreens(_ ids: [Int]) {
|
|
442
530
|
guard !ids.isEmpty else { return }
|
|
443
531
|
DispatchQueue.main.asyncAfter(deadline: .now() + Self.screenDisposeDelay) { [weak self] in
|
|
444
532
|
guard let self else { return }
|
|
445
|
-
|
|
446
|
-
live.insert(self.rootRoute.id)
|
|
447
|
-
if let presented = self.presented { live.insert(presented.id) }
|
|
533
|
+
let live = Set(self.entries.map(\.id))
|
|
448
534
|
for id in ids where !live.contains(id) {
|
|
449
535
|
self.registry.unregister(id: id)
|
|
450
536
|
}
|