@momo-kits/native-kits 0.162.44-debug → 0.162.45-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 +45 -3
- package/README.md +2 -2
- package/compose/build.gradle.kts +9 -1
- package/compose/build.gradle.kts.backup +8 -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 +351 -111
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Badge.kt +20 -21
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Button.kt +2 -1
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Carousel.kt +4 -2
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Chip.kt +1 -1
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Icon.kt +1 -1
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Information.kt +1 -1
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Input.kt +9 -6
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputMoney.kt +11 -12
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputOTP.kt +4 -9
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputPhoneNumber.kt +2 -2
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputSearch.kt +10 -9
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputTextArea.kt +6 -6
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/PopupNotify.kt +55 -22
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/PopupPromotion.kt +12 -1
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/ProgressInfo.kt +7 -9
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Radio.kt +1 -1
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Slider.kt +13 -6
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Swipe.kt +13 -5
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Switch.kt +3 -2
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/TabView.kt +14 -6
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Tag.kt +19 -21
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Text.kt +2 -2
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Tooltip.kt +5 -2
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/DateTimePickerUtils.kt +3 -1
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/WheelPicker.kt +7 -15
- package/compose/src/commonMain/kotlin/vn/momo/kits/const/Typography.kt +2 -1
- package/compose/src/commonMain/kotlin/vn/momo/kits/layout/Item.kt +1 -2
- package/compose/src/commonMain/kotlin/vn/momo/kits/layout/ScreenLayout.kt +314 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/BottomSheet.kt +43 -4
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/ModalScreen.kt +8 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigation.kt +10 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/NavigationContainer.kt +4 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigator.kt +6 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/StackScreen.kt +197 -60
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTab.kt +127 -83
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTabBar.kt +54 -62
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/DuoBarCompression.kt +97 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/DuoTabBar.kt +377 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/Header.kt +122 -24
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderBackground.kt +34 -14
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderJourney.kt +3 -3
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderLocation.kt +1 -2
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderRight.kt +116 -26
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderUser.kt +1 -2
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/SideBar.kt +192 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/SnackBar.kt +2 -1
- package/compose/src/commonMain/kotlin/vn/momo/kits/platform/Platform.kt +3 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/platform/ReservedRegions.kt +13 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/utils/Utils.kt +13 -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/libs.versions.toml +1 -1
- package/gradle.properties +1 -1
- package/ios/Application/ApplicationEnvironment.swift +17 -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 +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 +107 -77
- 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 +385 -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 +140 -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 +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/FontScale.swift +3 -3
- package/ios/Typography/Text.swift +81 -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 +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/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,332 @@
|
|
|
1
|
+
//
|
|
2
|
+
// BottomTab.swift
|
|
3
|
+
// MoMoUIKits
|
|
4
|
+
//
|
|
5
|
+
// SwiftUI mirror of Compose's `navigation/bottomtab/BottomTab.kt`: tabbed screens, each in
|
|
6
|
+
// `StackScreen` chrome built from its own `Navigation`, over a `BottomTabBar` (or the iPhone Duo
|
|
7
|
+
// rail / floating bar). Switching tabs cross-fades + scales the content.
|
|
8
|
+
//
|
|
9
|
+
|
|
10
|
+
import SwiftUI
|
|
11
|
+
import UIKit
|
|
12
|
+
|
|
13
|
+
public struct BottomTab: View {
|
|
14
|
+
private let items: [BottomTabItem]
|
|
15
|
+
private let floatingButton: BottomTabFloatingButton?
|
|
16
|
+
private let compressionBehavior: DuoBarCompressionBehavior
|
|
17
|
+
private let onTabPress: ((_ index: Int, _ switchTab: @escaping () -> Void) -> Void)?
|
|
18
|
+
private let backToInitialTab: Bool
|
|
19
|
+
|
|
20
|
+
@EnvironmentObject private var navigator: Navigator
|
|
21
|
+
/// The screen hosting the tabs (Compose's `LocalNavigation` in BottomTab).
|
|
22
|
+
@OptionalEnvironmentObject private var hostNavigation: Navigation?
|
|
23
|
+
@Environment(\.screenLayout) private var layout
|
|
24
|
+
@Environment(\.appTheme) private var theme
|
|
25
|
+
@StateObject private var tabNavigations = TabNavigations()
|
|
26
|
+
/// Compose's `startRoute`: picked once from `initialRouteName`.
|
|
27
|
+
@State private var startIndex: Int
|
|
28
|
+
@State private var selectedIndex: Int
|
|
29
|
+
@State private var toolbarHeight = DuoBarCompression.defaultToolbarHeight
|
|
30
|
+
@State private var expandedBar: DuoBar?
|
|
31
|
+
@State private var openedBar: DuoBar?
|
|
32
|
+
|
|
33
|
+
/// `compressionBehavior`: on iPhone Duo, when the side bar can't hold both bars, which one
|
|
34
|
+
/// starts compressed; tapping the compressed one swaps them.
|
|
35
|
+
/// `onTabPress` gets every tab press with the switch to perform, e.g. after a login gate.
|
|
36
|
+
/// `backToInitialTab`: back off another tab returns to the initial one before leaving the screen.
|
|
37
|
+
public init(
|
|
38
|
+
items: [BottomTabItem],
|
|
39
|
+
floatingButton: BottomTabFloatingButton? = nil,
|
|
40
|
+
compressionBehavior: DuoBarCompressionBehavior = .toolbar,
|
|
41
|
+
initialRouteName: String? = nil,
|
|
42
|
+
onTabPress: ((_ index: Int, _ switchTab: @escaping () -> Void) -> Void)? = nil,
|
|
43
|
+
backToInitialTab: Bool = true
|
|
44
|
+
) {
|
|
45
|
+
self.items = items
|
|
46
|
+
self.floatingButton = floatingButton
|
|
47
|
+
self.compressionBehavior = compressionBehavior
|
|
48
|
+
self.onTabPress = onTabPress
|
|
49
|
+
self.backToInitialTab = backToInitialTab
|
|
50
|
+
let start = items.firstIndex { $0.name == initialRouteName } ?? 0
|
|
51
|
+
_startIndex = State(initialValue: start)
|
|
52
|
+
_selectedIndex = State(initialValue: start)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private var bottomInset: CGFloat { safeAreaBottomInset() }
|
|
56
|
+
// the Duo bars overlay the content or sit in the side strip, so only the home indicator is kept clear
|
|
57
|
+
private var reservedBottom: CGFloat {
|
|
58
|
+
layout.isIPhoneDuo ? layout.insets.bottom : BOTTOM_TAB_BAR_HEIGHT + bottomInset + Spacing.S
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
private var compression: DuoBarCompression {
|
|
62
|
+
DuoBarCompression.resolve(
|
|
63
|
+
layout: layout,
|
|
64
|
+
tabCount: items.count,
|
|
65
|
+
hasQr: floatingButton != nil,
|
|
66
|
+
toolbarHeight: toolbarHeight,
|
|
67
|
+
// 'toolbar' compression keeps the tab bar whole until the toolbar is tapped
|
|
68
|
+
expanded: Binding(
|
|
69
|
+
get: { expandedBar ?? (compressionBehavior == .toolbar ? .tabBar : .toolbar) },
|
|
70
|
+
set: { expandedBar = $0 }
|
|
71
|
+
),
|
|
72
|
+
opened: $openedBar,
|
|
73
|
+
setToolbarHeight: { height in
|
|
74
|
+
if toolbarHeight != height { toolbarHeight = height }
|
|
75
|
+
}
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private func duoInsets(_ compression: DuoBarCompression) -> DuoTabBarInsets {
|
|
80
|
+
layout.duoTabBarInsets(
|
|
81
|
+
itemCount: items.count,
|
|
82
|
+
hasFloatingButton: floatingButton != nil,
|
|
83
|
+
tabBarCompressed: compression.tabBarCompressed
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
public var body: some View {
|
|
88
|
+
let compression = compression
|
|
89
|
+
ZStack(alignment: .bottom) {
|
|
90
|
+
// Tab content area, inset above the bar.
|
|
91
|
+
TabHost(selectedIndex: selectedIndex, count: items.count) { index in AnyView(tabScreen(for: index)) }
|
|
92
|
+
// each tab hosts itself, so it needs the window's safe area to draw its header under the status bar
|
|
93
|
+
.ignoresSafeArea(.container, edges: [.top, .horizontal])
|
|
94
|
+
.padding(.bottom, reservedBottom)
|
|
95
|
+
.environment(\.duoTabBarInsets, duoInsets(compression))
|
|
96
|
+
.environment(\.duoBarCompression, compression)
|
|
97
|
+
|
|
98
|
+
if layout.isIPhoneDuo {
|
|
99
|
+
DuoTabBar(
|
|
100
|
+
items: items,
|
|
101
|
+
floatingButton: floatingButton,
|
|
102
|
+
selectedIndex: selectedIndex,
|
|
103
|
+
onTabSelected: selectTab,
|
|
104
|
+
compression: compression
|
|
105
|
+
)
|
|
106
|
+
} else {
|
|
107
|
+
// Tab bar + home-indicator filler.
|
|
108
|
+
VStack(spacing: 0) {
|
|
109
|
+
BottomTabBar(
|
|
110
|
+
items: items,
|
|
111
|
+
floatingButton: floatingButton,
|
|
112
|
+
selectedIndex: selectedIndex,
|
|
113
|
+
onTabSelected: selectTab
|
|
114
|
+
)
|
|
115
|
+
theme.colors.background.surface
|
|
116
|
+
.frame(height: bottomInset + Spacing.S)
|
|
117
|
+
.ignoresSafeArea(edges: .horizontal)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
122
|
+
.ignoresSafeArea(edges: .bottom)
|
|
123
|
+
.preference(key: IsBottomTabRootKey.self, value: true)
|
|
124
|
+
.onAppear {
|
|
125
|
+
hostNavigation?.setOptions {
|
|
126
|
+
$0.headerType = .none
|
|
127
|
+
$0.useAvoidKeyboard = false
|
|
128
|
+
}
|
|
129
|
+
updateTabBack()
|
|
130
|
+
}
|
|
131
|
+
.onChange(of: selectedIndex) { _ in updateTabBack() }
|
|
132
|
+
.onDisappear {
|
|
133
|
+
// both live outside this view
|
|
134
|
+
navigator.tabBackHandler = nil
|
|
135
|
+
HostBackGestureBridge.shared.shouldUseFloatingArrowBack = false
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private func selectTab(_ index: Int) {
|
|
140
|
+
let switchTab = { if index != selectedIndex { selectedIndex = index } }
|
|
141
|
+
if let onTabPress { onTabPress(index, switchTab) } else { switchTab() }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/// Back off the initial tab returns to it; the host shows its floating arrow there (RN parity).
|
|
145
|
+
private func updateTabBack() {
|
|
146
|
+
let isFirstTab = selectedIndex == startIndex || !backToInitialTab
|
|
147
|
+
HostBackGestureBridge.shared.shouldUseFloatingArrowBack = !isFirstTab
|
|
148
|
+
navigator.tabBackHandler = isFirstTab ? nil : (hostNavigation?.id ?? -1, { selectedIndex = startIndex })
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private func tabScreen(for index: Int) -> some View {
|
|
152
|
+
let item = items[index]
|
|
153
|
+
return TabScreenHost(
|
|
154
|
+
navigation: tabNavigations.navigation(for: index, options: item.options),
|
|
155
|
+
screen: item.screen,
|
|
156
|
+
goBack: { navigator.onBackSafe() }
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/// Keeps each tab's `Navigation`, so options a tab set at runtime survive switching away.
|
|
162
|
+
private final class TabNavigations: ObservableObject {
|
|
163
|
+
private var navigations: [Int: Navigation] = [:]
|
|
164
|
+
|
|
165
|
+
func navigation(for index: Int, options: NavigationOptions?) -> Navigation {
|
|
166
|
+
if let navigation = navigations[index] { return navigation }
|
|
167
|
+
let navigation = Navigation(bottomTabIndex: index, options: options)
|
|
168
|
+
navigations[index] = navigation
|
|
169
|
+
return navigation
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private struct TabScreenHost: View {
|
|
174
|
+
@ObservedObject var navigation: Navigation
|
|
175
|
+
let screen: () -> AnyView
|
|
176
|
+
let goBack: () -> Void
|
|
177
|
+
|
|
178
|
+
var body: some View {
|
|
179
|
+
OptionsStackScreen(
|
|
180
|
+
options: navigation.options,
|
|
181
|
+
screenName: navigation.options.screenName ?? "",
|
|
182
|
+
goBack: goBack,
|
|
183
|
+
content: screen
|
|
184
|
+
)
|
|
185
|
+
.environmentObject(navigation)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// MARK: - Tab host
|
|
190
|
+
|
|
191
|
+
/// Compose's tab NavHost with `saveState` / `restoreState`. A tab gets its own UIHostingController
|
|
192
|
+
/// on first selection and keeps it, so its state and scroll survive a switch; while hidden it is
|
|
193
|
+
/// out of the window, so it gets onDisappear / onAppear (and re-runs screen tracking) like a NavHost
|
|
194
|
+
/// destination or a UITabBarController tab. Same scheme as `StackController`.
|
|
195
|
+
private struct TabHost: UIViewControllerRepresentable {
|
|
196
|
+
let selectedIndex: Int
|
|
197
|
+
let count: Int
|
|
198
|
+
let content: (Int) -> AnyView
|
|
199
|
+
|
|
200
|
+
func makeUIViewController(context: Context) -> TabController {
|
|
201
|
+
TabController()
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
func updateUIViewController(_ controller: TabController, context: Context) {
|
|
205
|
+
controller.update(selectedIndex: selectedIndex, count: count, environment: context.environment, content: content)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private final class TabController: UIViewController {
|
|
210
|
+
private final class Tab {
|
|
211
|
+
let motion = TabMotion()
|
|
212
|
+
var host: UIHostingController<AnyView>!
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
private static let switchDuration: TimeInterval = 0.2
|
|
216
|
+
|
|
217
|
+
private var tabs: [Int: Tab] = [:]
|
|
218
|
+
private var selectedIndex: Int?
|
|
219
|
+
private var environment = EnvironmentValues()
|
|
220
|
+
private var content: (Int) -> AnyView = { _ in AnyView(EmptyView()) }
|
|
221
|
+
|
|
222
|
+
override func viewDidLoad() {
|
|
223
|
+
super.viewDidLoad()
|
|
224
|
+
view.backgroundColor = .clear
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
override func viewDidLayoutSubviews() {
|
|
228
|
+
super.viewDidLayoutSubviews()
|
|
229
|
+
for subview in view.subviews { subview.frame = view.bounds }
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
func update(
|
|
233
|
+
selectedIndex index: Int,
|
|
234
|
+
count: Int,
|
|
235
|
+
environment: EnvironmentValues,
|
|
236
|
+
content: @escaping (Int) -> AnyView
|
|
237
|
+
) {
|
|
238
|
+
self.environment = environment
|
|
239
|
+
self.content = content
|
|
240
|
+
for (stale, removed) in tabs where stale >= count {
|
|
241
|
+
tabs[stale] = nil
|
|
242
|
+
tearDown(removed)
|
|
243
|
+
}
|
|
244
|
+
if let selectedIndex, tabs[selectedIndex] == nil { self.selectedIndex = nil }
|
|
245
|
+
// hidden tabs too, so a tab coming back already shows the latest items and environment
|
|
246
|
+
for (tabIndex, existing) in tabs { existing.host.rootView = rootView(tabIndex, existing) }
|
|
247
|
+
guard (0..<count).contains(index), index != selectedIndex else { return }
|
|
248
|
+
|
|
249
|
+
let previous = selectedIndex.flatMap { tabs[$0] }
|
|
250
|
+
selectedIndex = index
|
|
251
|
+
let selected = tabs[index] ?? makeTab(index)
|
|
252
|
+
attach(selected)
|
|
253
|
+
|
|
254
|
+
guard let previous, view.window != nil else {
|
|
255
|
+
selected.motion.visible = true
|
|
256
|
+
previous.map(leave)
|
|
257
|
+
return
|
|
258
|
+
}
|
|
259
|
+
previous.host.view.isUserInteractionEnabled = false
|
|
260
|
+
// a frame at the start values first, so a tab created just now fades in too
|
|
261
|
+
DispatchQueue.main.async {
|
|
262
|
+
selected.motion.visible = true
|
|
263
|
+
previous.motion.visible = false
|
|
264
|
+
}
|
|
265
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + Self.switchDuration) { [weak self] in
|
|
266
|
+
self?.leave(previous)
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
private func makeTab(_ index: Int) -> Tab {
|
|
271
|
+
let tab = Tab()
|
|
272
|
+
let host = UIHostingController(rootView: rootView(index, tab))
|
|
273
|
+
host.view.backgroundColor = .clear
|
|
274
|
+
addChild(host)
|
|
275
|
+
host.didMove(toParent: self)
|
|
276
|
+
tab.host = host
|
|
277
|
+
tabs[index] = tab
|
|
278
|
+
return tab
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
private func rootView(_ index: Int, _ tab: Tab) -> AnyView {
|
|
282
|
+
AnyView(TabCard(motion: tab.motion, content: content(index)).environment(\.self, environment))
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
private func attach(_ tab: Tab) {
|
|
286
|
+
let hostView: UIView = tab.host.view
|
|
287
|
+
hostView.isUserInteractionEnabled = true
|
|
288
|
+
hostView.frame = view.bounds
|
|
289
|
+
if hostView.superview === view {
|
|
290
|
+
view.bringSubviewToFront(hostView)
|
|
291
|
+
} else {
|
|
292
|
+
view.addSubview(hostView)
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/// Off the window once hidden; a tab selected again in the meantime stays.
|
|
297
|
+
private func leave(_ tab: Tab) {
|
|
298
|
+
guard selectedIndex.flatMap({ tabs[$0] }) !== tab else { return }
|
|
299
|
+
tab.host.view.removeFromSuperview()
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
private func tearDown(_ tab: Tab) {
|
|
303
|
+
tab.host.willMove(toParent: nil)
|
|
304
|
+
tab.host.view.removeFromSuperview()
|
|
305
|
+
tab.host.removeFromParent()
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
private final class TabMotion: ObservableObject {
|
|
310
|
+
@Published var visible = false
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/// The tab switch: 0.97 scale + fade over 200ms, both ways (Compose's tab enter / exit transitions).
|
|
314
|
+
private struct TabCard: View {
|
|
315
|
+
@ObservedObject var motion: TabMotion
|
|
316
|
+
let content: AnyView
|
|
317
|
+
|
|
318
|
+
var body: some View {
|
|
319
|
+
content
|
|
320
|
+
.opacity(motion.visible ? 1 : 0)
|
|
321
|
+
.scaleEffect(motion.visible ? 1 : 0.97)
|
|
322
|
+
.animation(.easeInOut(duration: 0.2), value: motion.visible)
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/// Bottom safe-area inset (home indicator).
|
|
327
|
+
private func safeAreaBottomInset() -> CGFloat {
|
|
328
|
+
let bottomInset = UIApplication.shared.connectedScenes
|
|
329
|
+
.compactMap { ($0 as? UIWindowScene)?.keyWindow?.safeAreaInsets.bottom }
|
|
330
|
+
.first ?? 0
|
|
331
|
+
return min(bottomInset, 21)
|
|
332
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// BottomTabBar.swift
|
|
3
3
|
// MoMoUIKits
|
|
4
4
|
//
|
|
5
|
-
// SwiftUI mirror of Compose's `
|
|
5
|
+
// SwiftUI mirror of Compose's `navigation/bottomtab/BottomTabBar.kt`.
|
|
6
6
|
// Renders the row of tab items, an animated selection indicator, a top shadow,
|
|
7
7
|
// badges/dots, and an optional center floating button (with the curved notch).
|
|
8
8
|
//
|
|
@@ -20,6 +20,7 @@ public struct BottomTabItem: Identifiable {
|
|
|
20
20
|
public let showDot: Bool
|
|
21
21
|
public let badgeLabel: String?
|
|
22
22
|
public let options: NavigationOptions?
|
|
23
|
+
public let initialParams: Any?
|
|
23
24
|
public let screen: () -> AnyView
|
|
24
25
|
|
|
25
26
|
public init(
|
|
@@ -29,6 +30,7 @@ public struct BottomTabItem: Identifiable {
|
|
|
29
30
|
showDot: Bool = false,
|
|
30
31
|
badgeLabel: String? = nil,
|
|
31
32
|
options: NavigationOptions? = nil,
|
|
33
|
+
initialParams: Any? = nil,
|
|
32
34
|
@ViewBuilder screen: @escaping () -> some View
|
|
33
35
|
) {
|
|
34
36
|
self.name = name
|
|
@@ -37,6 +39,7 @@ public struct BottomTabItem: Identifiable {
|
|
|
37
39
|
self.showDot = showDot
|
|
38
40
|
self.badgeLabel = badgeLabel
|
|
39
41
|
self.options = options
|
|
42
|
+
self.initialParams = initialParams
|
|
40
43
|
self.screen = { AnyView(screen()) }
|
|
41
44
|
}
|
|
42
45
|
}
|
|
@@ -65,7 +68,11 @@ struct BottomTabBar: View {
|
|
|
65
68
|
let selectedIndex: Int
|
|
66
69
|
let onTabSelected: (Int) -> Void
|
|
67
70
|
|
|
68
|
-
private var
|
|
71
|
+
@Environment(\.screenLayout) private var layout
|
|
72
|
+
@Environment(\.appTheme) private var theme
|
|
73
|
+
|
|
74
|
+
// the bar sits between the side safe areas
|
|
75
|
+
private var screenWidth: CGFloat { layout.width - layout.insets.leading - layout.insets.trailing }
|
|
69
76
|
private var mid: Int { items.count / 2 }
|
|
70
77
|
|
|
71
78
|
private var tabWidth: CGFloat {
|
|
@@ -95,11 +102,11 @@ struct BottomTabBar: View {
|
|
|
95
102
|
}
|
|
96
103
|
.frame(maxWidth: .infinity)
|
|
97
104
|
.frame(height: BOTTOM_TAB_BAR_HEIGHT)
|
|
98
|
-
.background(
|
|
105
|
+
.background(theme.colors.background.surface.ignoresSafeArea(edges: .horizontal))
|
|
99
106
|
|
|
100
107
|
// Animated selection indicator.
|
|
101
108
|
RoundedRectangle(cornerRadius: 2)
|
|
102
|
-
.fill(
|
|
109
|
+
.fill(theme.colors.primary)
|
|
103
110
|
.frame(width: 44, height: 2)
|
|
104
111
|
.offset(x: indicatorOffset, y: -BOTTOM_TAB_BAR_HEIGHT + 2)
|
|
105
112
|
.animation(.easeInOut(duration: 0.25), value: selectedIndex)
|
|
@@ -140,7 +147,9 @@ private struct TabBarItem: View {
|
|
|
140
147
|
let selected: Bool
|
|
141
148
|
let onClick: () -> Void
|
|
142
149
|
|
|
143
|
-
private var
|
|
150
|
+
@Environment(\.appTheme) private var theme
|
|
151
|
+
|
|
152
|
+
private var tintColor: Color { selected ? theme.colors.primary : theme.colors.text.hint }
|
|
144
153
|
|
|
145
154
|
/// Mirrors Compose's `formatLabel`: hide a badge that is the number "0".
|
|
146
155
|
private var badgeLabel: String? {
|
|
@@ -192,18 +201,20 @@ private struct TabBarItem: View {
|
|
|
192
201
|
private struct CenterFloatingButton: View {
|
|
193
202
|
let data: BottomTabFloatingButton
|
|
194
203
|
|
|
204
|
+
@Environment(\.appTheme) private var theme
|
|
205
|
+
|
|
195
206
|
var body: some View {
|
|
196
207
|
VStack(spacing: 0) {
|
|
197
208
|
CurvedContainer()
|
|
198
209
|
Circle()
|
|
199
|
-
.fill(
|
|
210
|
+
.fill(theme.colors.primary)
|
|
200
211
|
.frame(width: 48, height: 48)
|
|
201
212
|
.overlay(Icon(source: data.icon, size: 28, color: .white))
|
|
202
213
|
Spacer().frame(height: 4)
|
|
203
214
|
MomoText(data.label, typography: .labelXsMedium, color: Colors.black01, textAlign: .center, maxLines: 1)
|
|
204
215
|
.frame(maxWidth: .infinity)
|
|
205
216
|
.frame(height: 14)
|
|
206
|
-
.background(
|
|
217
|
+
.background(theme.colors.primary)
|
|
207
218
|
.clipShape(RoundedRectangle(cornerRadius: Radius.XL))
|
|
208
219
|
}
|
|
209
220
|
.frame(width: bottomTabFloatingButtonWidth)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
//
|
|
2
|
+
// DuoBarCompression.swift
|
|
3
|
+
// MoMoUIKits
|
|
4
|
+
//
|
|
5
|
+
// Port of RN `BottomTab/DuoBarCompression.tsx` (HIG, Designing for iPhone Duo: Vertical controls):
|
|
6
|
+
// keeps the side bar's toolbar and tab bar clear of each other.
|
|
7
|
+
//
|
|
8
|
+
|
|
9
|
+
import SwiftUI
|
|
10
|
+
|
|
11
|
+
/// iPhone Duo: which side bar bar starts compressed when the strip cannot hold both whole.
|
|
12
|
+
public enum DuoBarCompressionBehavior {
|
|
13
|
+
case toolbar, tabBar
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
enum DuoBar {
|
|
17
|
+
case toolbar, tabBar
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
struct DuoBarCompression {
|
|
21
|
+
var toolbarCompressed = false
|
|
22
|
+
var tabBarCompressed = false
|
|
23
|
+
/// The open rail needs the toolbar button slot too: only Back stays.
|
|
24
|
+
var toolbarHidden = false
|
|
25
|
+
var expandToolbar: () -> Void = {}
|
|
26
|
+
var expandTabBar: () -> Void = {}
|
|
27
|
+
/// A rail opened from the compressed state closes once a tab is picked.
|
|
28
|
+
var onTabSelected: () -> Void = {}
|
|
29
|
+
/// Side bar toolkits report their full height so the tab bar knows what is left.
|
|
30
|
+
var setToolbarHeight: (CGFloat) -> Void = { _ in }
|
|
31
|
+
|
|
32
|
+
/// Space kept between the top and the bottom groups (HIG Vertical controls).
|
|
33
|
+
static let groupGap = Spacing.M
|
|
34
|
+
/// Back, then the right toolkit: help + home capsule.
|
|
35
|
+
static let defaultToolbarHeight = DuoMetrics.controlSize + Spacing.M + 2 * DuoMetrics.itemPitch
|
|
36
|
+
/// Back, then the single button the toolbar folds into.
|
|
37
|
+
private static let compressedToolbarHeight = 2 * DuoMetrics.controlSize + Spacing.M
|
|
38
|
+
|
|
39
|
+
/// - both fit: both stay whole
|
|
40
|
+
/// - one fits whole beside the other compressed: one is compressed, tapping it swaps them
|
|
41
|
+
/// - otherwise both rest compressed; tapping one opens it for a moment (the rail closes
|
|
42
|
+
/// again once a tab is picked)
|
|
43
|
+
static func resolve(
|
|
44
|
+
layout: ScreenLayout,
|
|
45
|
+
tabCount: Int,
|
|
46
|
+
hasQr: Bool,
|
|
47
|
+
toolbarHeight: CGFloat,
|
|
48
|
+
expanded: Binding<DuoBar>,
|
|
49
|
+
opened: Binding<DuoBar?>,
|
|
50
|
+
setToolbarHeight: @escaping (CGFloat) -> Void
|
|
51
|
+
) -> DuoBarCompression {
|
|
52
|
+
guard let sideBar = layout.sideBar else { return DuoBarCompression() }
|
|
53
|
+
let room = layout.height - sideBar.bottom - sideBar.top - groupGap
|
|
54
|
+
let tabBar = duoRailHeight(itemCount: tabCount, hasQr: hasQr)
|
|
55
|
+
let compressedTabBar = duoRailHeight(itemCount: 1, hasQr: hasQr, compressed: true)
|
|
56
|
+
func fits(_ toolbar: CGFloat, _ tabs: CGFloat) -> Bool { toolbar + tabs <= room }
|
|
57
|
+
|
|
58
|
+
if fits(toolbarHeight, tabBar) {
|
|
59
|
+
return DuoBarCompression(setToolbarHeight: setToolbarHeight)
|
|
60
|
+
}
|
|
61
|
+
if fits(compressedToolbarHeight, tabBar) && fits(toolbarHeight, compressedTabBar) {
|
|
62
|
+
return DuoBarCompression(
|
|
63
|
+
toolbarCompressed: expanded.wrappedValue == .tabBar,
|
|
64
|
+
tabBarCompressed: expanded.wrappedValue == .toolbar,
|
|
65
|
+
expandToolbar: { expanded.wrappedValue = .toolbar },
|
|
66
|
+
expandTabBar: { expanded.wrappedValue = .tabBar },
|
|
67
|
+
setToolbarHeight: setToolbarHeight
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
let expandToolbar = { opened.wrappedValue = .toolbar }
|
|
72
|
+
let expandTabBar = { opened.wrappedValue = .tabBar }
|
|
73
|
+
// an opened rail always shows, even when only the group gap is short: it closes once a tab is picked
|
|
74
|
+
if opened.wrappedValue == .tabBar {
|
|
75
|
+
return DuoBarCompression(
|
|
76
|
+
toolbarCompressed: true,
|
|
77
|
+
tabBarCompressed: false,
|
|
78
|
+
toolbarHidden: true,
|
|
79
|
+
expandToolbar: expandToolbar,
|
|
80
|
+
expandTabBar: expandTabBar,
|
|
81
|
+
onTabSelected: { opened.wrappedValue = nil },
|
|
82
|
+
setToolbarHeight: setToolbarHeight
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
return DuoBarCompression(
|
|
86
|
+
toolbarCompressed: opened.wrappedValue != .toolbar,
|
|
87
|
+
tabBarCompressed: true,
|
|
88
|
+
expandToolbar: expandToolbar,
|
|
89
|
+
expandTabBar: expandTabBar,
|
|
90
|
+
setToolbarHeight: setToolbarHeight
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private struct DuoBarCompressionKey: EnvironmentKey {
|
|
96
|
+
static let defaultValue = DuoBarCompression()
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
extension EnvironmentValues {
|
|
100
|
+
var duoBarCompression: DuoBarCompression {
|
|
101
|
+
get { self[DuoBarCompressionKey.self] }
|
|
102
|
+
set { self[DuoBarCompressionKey.self] = newValue }
|
|
103
|
+
}
|
|
104
|
+
}
|