@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.
Files changed (199) hide show
  1. package/CLAUDE.md +45 -3
  2. package/README.md +2 -2
  3. package/compose/build.gradle.kts +9 -1
  4. package/compose/build.gradle.kts.backup +8 -0
  5. package/compose/compose.podspec +1 -1
  6. package/compose/src/androidMain/kotlin/vn/momo/kits/platform/Platform.android.kt +2 -0
  7. package/compose/src/androidMain/kotlin/vn/momo/kits/platform/ReservedRegions.android.kt +6 -0
  8. package/compose/src/commonMain/kotlin/vn/momo/kits/application/LiteScreen.kt +351 -111
  9. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Badge.kt +20 -21
  10. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Button.kt +2 -1
  11. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Carousel.kt +4 -2
  12. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Chip.kt +1 -1
  13. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Icon.kt +1 -1
  14. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Information.kt +1 -1
  15. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Input.kt +9 -6
  16. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputMoney.kt +11 -12
  17. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputOTP.kt +4 -9
  18. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputPhoneNumber.kt +2 -2
  19. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputSearch.kt +10 -9
  20. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputTextArea.kt +6 -6
  21. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PopupNotify.kt +55 -22
  22. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PopupPromotion.kt +12 -1
  23. package/compose/src/commonMain/kotlin/vn/momo/kits/components/ProgressInfo.kt +7 -9
  24. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Radio.kt +1 -1
  25. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Slider.kt +13 -6
  26. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Swipe.kt +13 -5
  27. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Switch.kt +3 -2
  28. package/compose/src/commonMain/kotlin/vn/momo/kits/components/TabView.kt +14 -6
  29. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Tag.kt +19 -21
  30. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Text.kt +2 -2
  31. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Tooltip.kt +5 -2
  32. package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/DateTimePickerUtils.kt +3 -1
  33. package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/WheelPicker.kt +7 -15
  34. package/compose/src/commonMain/kotlin/vn/momo/kits/const/Typography.kt +2 -1
  35. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/Item.kt +1 -2
  36. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/ScreenLayout.kt +314 -0
  37. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/BottomSheet.kt +43 -4
  38. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/ModalScreen.kt +8 -0
  39. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigation.kt +10 -0
  40. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/NavigationContainer.kt +4 -0
  41. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigator.kt +6 -0
  42. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/StackScreen.kt +197 -60
  43. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTab.kt +127 -83
  44. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTabBar.kt +54 -62
  45. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/DuoBarCompression.kt +97 -0
  46. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/DuoTabBar.kt +377 -0
  47. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/Header.kt +122 -24
  48. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderBackground.kt +34 -14
  49. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderJourney.kt +3 -3
  50. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderLocation.kt +1 -2
  51. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderRight.kt +116 -26
  52. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderUser.kt +1 -2
  53. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/SideBar.kt +192 -0
  54. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/SnackBar.kt +2 -1
  55. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/Platform.kt +3 -0
  56. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/ReservedRegions.kt +13 -0
  57. package/compose/src/commonMain/kotlin/vn/momo/kits/utils/Utils.kt +13 -0
  58. package/compose/src/iosMain/kotlin/vn/momo/kits/platform/Platform.ios.kt +4 -0
  59. package/compose/src/iosMain/kotlin/vn/momo/kits/platform/ReservedRegions.ios.kt +94 -0
  60. package/gradle/libs.versions.toml +1 -1
  61. package/gradle.properties +1 -1
  62. package/ios/Application/ApplicationEnvironment.swift +17 -0
  63. package/ios/Application/MaxApi.swift +30 -3
  64. package/ios/Avatar/Avatar.swift +4 -3
  65. package/ios/Badge/Badge.swift +6 -4
  66. package/ios/Badge/BadgeDot.swift +1 -1
  67. package/ios/Badge/BadgeRibbon.swift +0 -1
  68. package/ios/Button/Button.swift +15 -12
  69. package/ios/CalculatorKeyboard/CalculatorKeyboard.swift +64 -18
  70. package/ios/Carousel/Carousel.swift +29 -12
  71. package/ios/Checkbox/Checkbox.swift +7 -3
  72. package/ios/Chip/Chip.swift +5 -4
  73. package/ios/Collapse/Collapse.swift +70 -19
  74. package/ios/Colors+Radius+Spacing/Colors.swift +10 -1
  75. package/ios/Colors+Radius+Spacing/Radius.swift +6 -0
  76. package/ios/DateTimePicker/DateTimePicker.swift +54 -20
  77. package/ios/DateTimePicker/WheelPicker.swift +103 -105
  78. package/ios/Divider/Divider.swift +37 -4
  79. package/ios/Extensions/Color++.swift +38 -9
  80. package/ios/Extensions/OptionalEnvironmentObject.swift +12 -0
  81. package/ios/Extensions/SkeletonUICompat.swift +11 -0
  82. package/ios/Icon/Icon.swift +21 -23
  83. package/ios/Icon/Icons.swift +1329 -0
  84. package/ios/IconButton/IconButton.swift +15 -7
  85. package/ios/Image/Image.swift +7 -23
  86. package/ios/Information/Information.swift +27 -9
  87. package/ios/Input/ErrorView.swift +2 -2
  88. package/ios/Input/Input.swift +160 -147
  89. package/ios/Input/InputPhoneNumber.swift +12 -9
  90. package/ios/Input/InputSearch.swift +107 -77
  91. package/ios/Input/InputTextArea.swift +128 -145
  92. package/ios/InputDropDown/InputDropDown.swift +10 -7
  93. package/ios/InputMoney/InputMoney.swift +33 -11
  94. package/ios/InputOTP/InputOTP.swift +22 -12
  95. package/ios/Layout/Card.swift +50 -0
  96. package/ios/Layout/GridContext.swift +187 -3
  97. package/ios/Layout/Item.swift +5 -4
  98. package/ios/Layout/ScreenLayout.swift +385 -0
  99. package/ios/Layout/Section.swift +4 -100
  100. package/ios/Loader/Loader.swift +5 -9
  101. package/ios/Lottie/LottieView.swift +14 -2
  102. package/ios/{Application/Navigation/Overplay → Navigation}/BottomSheet.swift +169 -29
  103. package/ios/Navigation/BottomTab/BottomTab.swift +332 -0
  104. package/ios/{Application/Navigation → Navigation}/BottomTab/BottomTabBar.swift +18 -7
  105. package/ios/Navigation/BottomTab/DuoBarCompression.swift +104 -0
  106. package/ios/Navigation/BottomTab/DuoTabBar.swift +329 -0
  107. package/ios/{Application → Navigation/Component}/FloatingButton.swift +43 -54
  108. package/ios/Navigation/Component/Header.swift +545 -0
  109. package/ios/Navigation/Component/HeaderBackground.swift +154 -0
  110. package/ios/Navigation/Component/HeaderJourney.swift +73 -0
  111. package/ios/Navigation/Component/HeaderLocation.swift +48 -0
  112. package/ios/Navigation/Component/HeaderRight.swift +425 -0
  113. package/ios/Navigation/Component/HeaderTitle.swift +36 -0
  114. package/ios/{Application/Navigation → Navigation/Component}/HeaderUser.swift +21 -24
  115. package/ios/Navigation/Component/SideBar.swift +183 -0
  116. package/ios/{Application/Navigation/Overplay → Navigation/Component}/SnackBar.swift +8 -8
  117. package/ios/Navigation/HostBackGestureBridge.swift +78 -0
  118. package/ios/{Application/Navigation/Overplay → Navigation}/ModalScreen.swift +9 -2
  119. package/ios/Navigation/Navigation.swift +204 -0
  120. package/ios/Navigation/NavigationContainer.swift +195 -0
  121. package/ios/{Application/Navigation → Navigation}/Navigator.swift +207 -107
  122. package/ios/Navigation/StackScreen.swift +930 -0
  123. package/ios/Navigation/StackTransition.swift +400 -0
  124. package/ios/Navigation/SwipeBackHaptic.swift +33 -0
  125. package/ios/{Application/Navigation → Navigation}/Tracking/ScreenTracker.swift +12 -5
  126. package/ios/OTPKeyboard/OTPKeyboard.swift +8 -11
  127. package/ios/Pagination/PaginationScroll.swift +2 -2
  128. package/ios/Popup/PopupDisplay.swift +72 -135
  129. package/ios/Popup/PopupInput.swift +10 -13
  130. package/ios/Popup/PopupLayout.swift +140 -0
  131. package/ios/Popup/PopupNotify.swift +59 -69
  132. package/ios/Popup/PopupPromotion.swift +31 -47
  133. package/ios/PopupView/FullscreenPopup.swift +13 -19
  134. package/ios/PopupView/PopupView.swift +1 -1
  135. package/ios/PopupView/Utils++.swift +0 -8
  136. package/ios/ProgressInfo/ProgressInfo.swift +4 -2
  137. package/ios/Radio/Radio.swift +2 -1
  138. package/ios/Rating/Rating.swift +9 -1
  139. package/ios/Resources/dot_loading.json +558 -0
  140. package/ios/Resources/lottie_circle_loader.json +1 -0
  141. package/ios/ScrollIndicator/ScrollIndicator.swift +11 -10
  142. package/ios/Skeleton/Skeleton.swift +22 -27
  143. package/ios/Slider/Slider.swift +16 -18
  144. package/ios/Stepper/Stepper.swift +6 -1
  145. package/ios/Steps/Steps.swift +8 -6
  146. package/ios/SuggestAction/SuggestAction.swift +5 -0
  147. package/ios/Swipeable/SwipeCell.swift +10 -6
  148. package/ios/Switch/Switch.swift +22 -20
  149. package/ios/TabView/TabView.swift +102 -21
  150. package/ios/Tag/Tag.swift +15 -4
  151. package/ios/Template/Logo/Logo.swift +2 -1
  152. package/ios/Template/TrustBanner/TrustBanner.swift +106 -54
  153. package/ios/Title/Title.swift +6 -17
  154. package/ios/Tooltip/Tooltip.swift +29 -14
  155. package/ios/Typography/FontScale.swift +3 -3
  156. package/ios/Typography/Text.swift +81 -19
  157. package/ios/Uploader/Uploader.swift +3 -1
  158. package/ios/native-kits.podspec +2 -1
  159. package/ios-demo/MoMoUIKitsDemo.podspec +1 -1
  160. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/ContentView.swift +1 -1
  161. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/DuoTabDemo.swift +80 -0
  162. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/Home.swift +2 -0
  163. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/IPhoneDuoDemo.swift +173 -0
  164. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/NavigationDemo.swift +77 -1
  165. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PopupDisplayDemo.swift +1 -0
  166. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PopupNotifyDemo.swift +2 -2
  167. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PopupPromotionDemo.swift +2 -0
  168. package/package.json +1 -1
  169. package/scripts/gen-ios-icons.mjs +36 -0
  170. package/compose/src/commonMain/kotlin/vn/momo/kits/application/AnimationSearchInput.kt +0 -52
  171. package/compose/src/commonMain/kotlin/vn/momo/kits/application/FloatingButton.kt +0 -183
  172. package/compose/src/commonMain/kotlin/vn/momo/kits/application/Header.kt +0 -207
  173. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderAnimated.kt +0 -39
  174. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderBackground.kt +0 -86
  175. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderDefault.kt +0 -76
  176. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderExtended.kt +0 -76
  177. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderRight.kt +0 -293
  178. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderTitle.kt +0 -33
  179. package/compose/src/commonMain/kotlin/vn/momo/kits/application/NavigationContainer.kt +0 -110
  180. package/compose/src/commonMain/kotlin/vn/momo/kits/application/Screen.kt +0 -378
  181. package/compose/src/commonMain/kotlin/vn/momo/kits/application/useHeaderSearchAnimation.kt +0 -69
  182. package/ios/Application/Components.swift +0 -270
  183. package/ios/Application/ComposeApi.swift +0 -22
  184. package/ios/Application/HeaderRight.swift +0 -306
  185. package/ios/Application/Navigation/BottomTab/BottomTab.swift +0 -104
  186. package/ios/Application/Navigation/HeaderBackProps.swift +0 -66
  187. package/ios/Application/Navigation/HeaderTitle.swift +0 -57
  188. package/ios/Application/Navigation/NavigationContainer.swift +0 -213
  189. package/ios/Application/Navigation/NavigationOptions.swift +0 -109
  190. package/ios/Application/Navigation/NavigatorManager.swift +0 -22
  191. package/ios/Application/Navigation/component/Header.swift +0 -178
  192. package/ios/Application/Navigation/component/HeaderBackground.swift +0 -83
  193. package/ios/Application/Navigation/component/HeaderColor.swift +0 -65
  194. package/ios/Application/Navigation/component/HeaderRightWidthKey.swift +0 -11
  195. package/ios/Application/Navigation/component/NavigationFooter.swift +0 -90
  196. package/ios/Application/Navigation/component/NavigationHeaderRight.swift +0 -196
  197. package/ios/Application/Screen.swift +0 -250
  198. package/ios/Application/StackScreen.swift +0 -492
  199. /package/ios/{Application/Navigation → Navigation}/BottomTab/CurvedContainer.swift +0 -0
@@ -1,27 +1,35 @@
1
1
  package vn.momo.kits.navigation.bottomtab
2
2
 
3
+ import androidx.compose.animation.core.Animatable
4
+ import androidx.compose.animation.core.AnimationVector1D
5
+ import androidx.compose.animation.core.Easing
3
6
  import androidx.compose.animation.core.tween
4
- import androidx.compose.animation.fadeIn
5
- import androidx.compose.animation.fadeOut
6
- import androidx.compose.animation.scaleIn
7
- import androidx.compose.animation.scaleOut
8
7
  import androidx.compose.foundation.background
9
8
  import androidx.compose.foundation.layout.*
10
9
  import androidx.compose.runtime.Composable
10
+ import androidx.compose.runtime.CompositionLocalProvider
11
11
  import androidx.compose.runtime.DisposableEffect
12
12
  import androidx.compose.runtime.LaunchedEffect
13
+ import androidx.compose.runtime.getValue
14
+ import androidx.compose.runtime.key
15
+ import androidx.compose.runtime.mutableIntStateOf
16
+ import androidx.compose.runtime.mutableStateOf
13
17
  import androidx.compose.runtime.remember
18
+ import androidx.compose.runtime.saveable.rememberSaveable
19
+ import androidx.compose.runtime.saveable.rememberSaveableStateHolder
20
+ import androidx.compose.runtime.setValue
21
+ import androidx.compose.runtime.staticCompositionLocalOf
14
22
  import androidx.compose.ui.Alignment
15
23
  import androidx.compose.ui.Modifier
24
+ import androidx.compose.ui.graphics.graphicsLayer
25
+ import androidx.compose.ui.layout.layout
16
26
  import androidx.compose.ui.unit.dp
17
- import androidx.navigation.compose.NavHost
18
- import androidx.navigation.compose.composable
19
- import androidx.navigation.compose.currentBackStackEntryAsState
20
- import androidx.navigation.compose.rememberNavController
27
+ import androidx.compose.ui.zIndex
21
28
  import vn.momo.kits.const.AppNavigationBar
22
29
  import vn.momo.kits.const.AppTheme
23
30
  import vn.momo.kits.const.Spacing
24
31
  import vn.momo.kits.navigation.KeyboardOptions
32
+ import vn.momo.kits.navigation.LocalMaxApi
25
33
  import vn.momo.kits.navigation.LocalNavigation
26
34
  import vn.momo.kits.navigation.LocalNavigator
27
35
  import vn.momo.kits.navigation.NavigationOptions
@@ -29,6 +37,18 @@ import vn.momo.kits.navigation.StackScreen
29
37
  import vn.momo.kits.navigation.component.HeaderType
30
38
  import vn.momo.kits.platform.BackHandler
31
39
  import vn.momo.kits.platform.reportFloatingArrowBack
40
+ import vn.momo.kits.layout.getScreenLayout
41
+ import kotlinx.coroutines.launch
42
+
43
+ private val inOutQuad = Easing { t -> if (t < 0.5f) 2 * t * t else 1 - 2 * (1 - t) * (1 - t) }
44
+ internal val tabTimingSpec = tween<Float>(durationMillis = 200, easing = inOutQuad)
45
+
46
+ internal val LocalBottomTabFocused = staticCompositionLocalOf { true }
47
+
48
+ private val Hidden = Modifier.layout { measurable, constraints ->
49
+ val placeable = measurable.measure(constraints)
50
+ layout(placeable.width, placeable.height) {}
51
+ }
32
52
 
33
53
  private var bottomTabOptionItems : MutableList<NavigationOptions?> = mutableListOf()
34
54
  internal fun setBottomTabOption(index: Int, options: NavigationOptions){
@@ -46,21 +66,21 @@ internal fun getBottomTabOption(index: Int): NavigationOptions? {
46
66
  fun BottomTab(
47
67
  items: List<BottomTabItem>,
48
68
  floatingButton: BottomTabFloatingButton? = null,
69
+ // iPhone Duo: when the side bar can't hold both bars, which one starts compressed;
70
+ // tapping the compressed one swaps them
71
+ compressionBehavior: DuoBarCompressionBehavior = DuoBarCompressionBehavior.TOOLBAR,
49
72
  initialRouteName: String? = null,
50
73
  onTabPress: ((index: Int, switchTab: () -> Unit) -> Unit)? = null,
51
74
  backToInitialTab: Boolean = true,
52
75
  ) {
53
76
  val navigation = LocalNavigation.current
54
77
  val navigator = LocalNavigator.current
55
- val navController = rememberNavController()
56
- val startRoute = remember {
57
- "option${items.indexOfFirst { it.name == initialRouteName }.coerceAtLeast(0)}"
58
- }
59
-
60
- bottomTabOptionItems = items.mapIndexed { index, item ->
61
- item.options ?: NavigationOptions()
62
- }.toMutableList()
78
+ val maxApi = LocalMaxApi.current
79
+ val startIndex = remember { items.indexOfFirst { it.name == initialRouteName }.coerceAtLeast(0) }
63
80
 
81
+ remember(items) {
82
+ bottomTabOptionItems = items.map { it.options ?: NavigationOptions() }.toMutableList()
83
+ }
64
84
 
65
85
  LaunchedEffect(Unit){
66
86
  navigation.markAsBottomTabRoot()
@@ -70,98 +90,122 @@ fun BottomTab(
70
90
  )
71
91
  }
72
92
 
73
- val currentRoute = navController.currentBackStackEntryAsState().value?.destination?.route
74
- val onStartTab = currentRoute == null || currentRoute == startRoute
75
- val isFirstTab = onStartTab || !backToInitialTab
76
- val backToStartTab = { navController.popBackStack(startRoute, inclusive = false) }
93
+ var selectedIndex by rememberSaveable { mutableIntStateOf(startIndex) }
94
+ var loadedTabs by rememberSaveable { mutableStateOf(listOf(startIndex)) }
95
+ val tabStateHolder = rememberSaveableStateHolder()
96
+ val selectTab: (Int) -> Unit = { index ->
97
+ if (index !in loadedTabs) loadedTabs = loadedTabs + index
98
+ selectedIndex = index
99
+ }
100
+
101
+ val onFirstTab = selectedIndex == 0
102
+ val isFirstTab = onFirstTab || !backToInitialTab
103
+ val backToFirstTab = { selectTab(0) }
104
+
105
+ var lastFocusedIndex by rememberSaveable { mutableStateOf<Int?>(null) }
106
+ val tabProgress = remember { mutableMapOf<Int, Animatable<Float, AnimationVector1D>>() }
107
+ fun progressOf(index: Int) = tabProgress.getOrPut(index) {
108
+ Animatable(if (index == lastFocusedIndex) 1f else 0f)
109
+ }
110
+ LaunchedEffect(selectedIndex) {
111
+ lastFocusedIndex = selectedIndex
112
+ tabProgress.forEach { (index, progress) ->
113
+ if (index != selectedIndex) launch { progress.animateTo(0f, tabTimingSpec) }
114
+ }
115
+ progressOf(selectedIndex).animateTo(1f, tabTimingSpec)
116
+ }
77
117
 
78
118
  DisposableEffect(isFirstTab) {
79
119
  reportFloatingArrowBack(!isFirstTab)
80
- navigator.tabBackHandler = if (isFirstTab) null else ({ backToStartTab() })
81
- // both live outside this composition
120
+ navigator.tabBackHandler = if (isFirstTab) null else ({ backToFirstTab() })
121
+
82
122
  onDispose {
83
123
  reportFloatingArrowBack(false)
84
124
  navigator.tabBackHandler = null
85
125
  }
86
126
  }
87
127
 
128
+ val layout = getScreenLayout()
129
+ val hasQr = floatingButton != null
130
+ val compression = rememberDuoBarCompression(compressionBehavior, items.size, hasQr, layout)
131
+ val railReserve = layout.sideBar?.takeIf { layout.isIPhoneDuo }?.let {
132
+ it.bottom + duoRailHeight(items.size, hasQr, compressed = compression.tabBarCompressed) + DUO_GROUP_GAP
133
+ } ?: 0.dp
134
+ val onTabSelected: (Int) -> Unit = { index ->
135
+ runCatching { maxApi?.triggerEventVibration("light", callback = null) }
136
+ val switchTab = {
137
+ if (index != selectedIndex) selectTab(index)
138
+ }
139
+ onTabPress?.invoke(index, switchTab) ?: switchTab()
140
+ }
141
+
88
142
  Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.BottomCenter) {
89
143
  Box(modifier = Modifier
90
144
  .fillMaxSize()
145
+ .background(AppTheme.current.colors.background.default)
91
146
  ) {
92
- NavHost(
93
- navController = navController,
94
- startDestination = startRoute
147
+ CompositionLocalProvider(
148
+ LocalDuoRailReserve provides railReserve,
149
+ LocalDuoBarCompression provides compression,
95
150
  ) {
96
- items.forEachIndexed { index, item ->
97
- composable(
98
- route = "option$index",
99
- enterTransition = {
100
- fadeIn(animationSpec = tween(200)) +
101
- scaleIn(
102
- initialScale = 0.97f,
103
- animationSpec = tween(200)
104
- )
105
- },
106
- exitTransition = {
107
- fadeOut(animationSpec = tween(200)) +
108
- scaleOut(
109
- targetScale = 0.97f,
110
- animationSpec = tween(200)
111
- )
112
- },
113
- popEnterTransition = {
114
- fadeIn(animationSpec = tween(200)) +
115
- scaleIn(
116
- initialScale = 0.97f,
117
- animationSpec = tween(200)
118
- )
119
- },
120
- popExitTransition = {
121
- fadeOut(animationSpec = tween(200)) +
122
- scaleOut(
123
- targetScale = 0.97f,
124
- animationSpec = tween(200)
151
+ loadedTabs.forEach { index ->
152
+ val item = items.getOrNull(index) ?: return@forEach
153
+ val focused = index == selectedIndex
154
+ val progress = progressOf(index)
155
+ key(index) {
156
+ Box(
157
+ Modifier
158
+ .fillMaxSize()
159
+ .zIndex(if (focused) 0f else -1f)
160
+ .then(if (focused) Modifier.graphicsLayer {
161
+ alpha = 0.3f + 0.7f * progress.value
162
+ scaleX = 0.97f + 0.03f * progress.value
163
+ scaleY = scaleX
164
+ } else Hidden)
165
+ ) {
166
+ CompositionLocalProvider(LocalBottomTabFocused provides focused) {
167
+ tabStateHolder.SaveableStateProvider(index) {
168
+ StackScreen(
169
+ content = item.screen,
170
+ navigationOptions = getBottomTabOption(index),
171
+ bottomTabIndex = index,
172
+ onBackHandler = {
173
+ if (backToInitialTab && index != 0) {
174
+ backToFirstTab()
175
+ } else {
176
+ navigator.pop()
177
+ }
178
+ }
125
179
  )
126
- }
127
- ) {
128
- val option = getBottomTabOption(index)
129
-
130
- StackScreen(
131
- content = item.screen,
132
- navigationOptions = option,
133
- bottomTabIndex = index,
134
- onBackHandler = {
135
- if (backToInitialTab && "option$index" != startRoute) {
136
- backToStartTab()
137
- } else {
138
- navigator.pop()
139
180
  }
140
181
  }
141
- )
182
+ }
142
183
  }
143
184
  }
144
185
  }
145
186
  }
146
- BackHandler(enabled = !onStartTab) {
147
- if (backToInitialTab) backToStartTab() else navigator.pop()
187
+ BackHandler(enabled = !onFirstTab) {
188
+ if (backToInitialTab) backToFirstTab() else navigator.pop()
148
189
  }
149
- Column {
150
- BottomTabBar(
190
+ if (layout.isIPhoneDuo) {
191
+ DuoTabBar(
192
+ layout = layout,
151
193
  items = items,
152
194
  floatingButton = floatingButton,
153
- navController = navController,
154
- onTabSelected = { index ->
155
- val switchTab = {
156
- val targetRoute = "option$index"
157
- if (navController.currentBackStackEntry?.destination?.route != targetRoute) {
158
- navController.navigate(targetRoute)
159
- }
160
- }
161
- onTabPress?.invoke(index, switchTab) ?: switchTab()
162
- }
195
+ selectedIndex = selectedIndex,
196
+ onTabSelected = onTabSelected,
197
+ compression = compression,
163
198
  )
164
- Spacer(modifier = Modifier.fillMaxWidth().height(AppNavigationBar.current + Spacing.S).background(AppTheme.current.colors.background.surface))
199
+ } else {
200
+ Column {
201
+ BottomTabBar(
202
+ items = items,
203
+ floatingButton = floatingButton,
204
+ selectedIndex = selectedIndex,
205
+ onTabSelected = onTabSelected,
206
+ )
207
+ Spacer(modifier = Modifier.fillMaxWidth().height(AppNavigationBar.current + Spacing.S).background(AppTheme.current.colors.background.surface))
208
+ }
165
209
  }
166
210
  }
167
211
  }
@@ -1,15 +1,18 @@
1
1
  package vn.momo.kits.navigation.bottomtab
2
2
 
3
- import androidx.compose.animation.core.FastOutSlowInEasing
4
- import androidx.compose.animation.core.animateDpAsState
5
- import androidx.compose.animation.core.tween
3
+ import androidx.compose.animation.core.Animatable
6
4
  import androidx.compose.foundation.background
7
5
  import androidx.compose.foundation.border
8
6
  import androidx.compose.foundation.layout.*
9
7
  import androidx.compose.foundation.shape.CircleShape
10
8
  import androidx.compose.foundation.shape.RoundedCornerShape
11
9
  import androidx.compose.runtime.Composable
10
+ import androidx.compose.runtime.LaunchedEffect
12
11
  import androidx.compose.runtime.getValue
12
+ import androidx.compose.runtime.mutableIntStateOf
13
+ import androidx.compose.runtime.remember
14
+ import androidx.compose.runtime.saveable.rememberSaveable
15
+ import androidx.compose.runtime.setValue
13
16
  import androidx.compose.ui.Alignment
14
17
  import androidx.compose.ui.Modifier
15
18
  import androidx.compose.ui.draw.clip
@@ -17,18 +20,18 @@ import androidx.compose.ui.graphics.Brush
17
20
  import androidx.compose.ui.graphics.Color
18
21
  import androidx.compose.ui.text.style.TextAlign
19
22
  import androidx.compose.ui.text.style.TextOverflow
23
+ import androidx.compose.ui.unit.Dp
24
+ import androidx.compose.ui.unit.IntOffset
20
25
  import androidx.compose.ui.unit.dp
21
- import androidx.navigation.NavController
22
- import androidx.navigation.compose.currentBackStackEntryAsState
23
26
  import vn.momo.kits.application.IsShowBaseLineDebug
24
27
  import vn.momo.kits.components.*
25
28
  import vn.momo.kits.const.*
26
29
  import vn.momo.kits.modifier.InternalApi
27
30
  import vn.momo.kits.modifier.conditional
28
- import vn.momo.kits.modifier.noFeedbackClickable
29
- import vn.momo.kits.platform.getScreenDimensions
31
+ import vn.momo.kits.modifier.activeOpacityClickable
32
+ import vn.momo.kits.layout.getScreenLayout
33
+ import vn.momo.kits.utils.horizontalSafeAreaPadding
30
34
 
31
- internal val floatingButtonWidth = 75.dp
32
35
  @InternalApi
33
36
  const val BOTTOM_TAB_BAR_HEIGHT = 64
34
37
 
@@ -36,28 +39,24 @@ const val BOTTOM_TAB_BAR_HEIGHT = 64
36
39
  internal fun BottomTabBar(
37
40
  items: List<BottomTabItem>,
38
41
  floatingButton: BottomTabFloatingButton? = null,
39
- navController: NavController,
42
+ selectedIndex: Int,
40
43
  onTabSelected: (Int) -> Unit,
41
44
  ) {
42
- val currentRoute = navController.currentBackStackEntryAsState().value?.destination?.route
43
- val selectedIndex = items.indexOfFirst { "option${items.indexOf(it)}" == currentRoute }
44
45
 
45
- val screenWidth = getScreenDimensions().width.dp
46
+ val layout = getScreenLayout()
46
47
  val mid = items.size / 2
47
48
 
48
- val tabWidth = if (floatingButton != null) {
49
- (screenWidth - floatingButtonWidth) / items.size
50
- } else {
51
- screenWidth / items.size
52
- }
53
- val adjustSize = if(floatingButton != null && selectedIndex >= mid) floatingButtonWidth else 0.dp
54
- val targetOffset = tabWidth * selectedIndex + tabWidth / 2 - screenWidth / 2 + adjustSize
49
+ val floating = floatingButton?.takeIf { items.size % 2 == 0 }
50
+ val slotWidth = layout.contentWidth / (items.size + if (floating != null) 1 else 0)
51
+ fun slotOf(index: Int) = if (floating != null && index >= mid) index + 1 else index
55
52
 
56
- val indicatorOffsetX by animateDpAsState(
57
- targetValue = targetOffset,
58
- animationSpec = tween(durationMillis = 250, easing = FastOutSlowInEasing),
59
- label = "IndicatorX"
60
- )
53
+ var indicatorSlot by rememberSaveable { mutableIntStateOf(0) }
54
+ val indicatorX = remember { Animatable(indicatorSlot.toFloat()) }
55
+ LaunchedEffect(selectedIndex) {
56
+ val target = slotOf(selectedIndex)
57
+ indicatorSlot = target
58
+ indicatorX.animateTo(target.toFloat(), tabTimingSpec)
59
+ }
61
60
 
62
61
  Box(
63
62
  contentAlignment = Alignment.BottomCenter
@@ -69,20 +68,21 @@ internal fun BottomTabBar(
69
68
  .background(AppTheme.current.colors.background.surface)
70
69
  .conditional(IsShowBaseLineDebug) {
71
70
  border(1.dp, Colors.blue_03)
72
- },
71
+ }
72
+ .horizontalSafeAreaPadding(),
73
73
  horizontalArrangement = Arrangement.SpaceAround,
74
74
  ) {
75
- if (floatingButton == null) {
75
+ if (floating == null) {
76
76
  RenderTabBarItem(items, 0, items.size, selectedIndex, onTabSelected)
77
77
  } else {
78
78
  RenderTabBarItem(items, 0, mid, selectedIndex, onTabSelected)
79
- Spacer(modifier = Modifier.width(floatingButtonWidth).padding(horizontal = Spacing.XXS))
79
+ Spacer(modifier = Modifier.width(slotWidth))
80
80
  RenderTabBarItem(items, mid, items.size, selectedIndex, onTabSelected)
81
81
  }
82
82
  }
83
83
 
84
84
  Box(modifier = Modifier
85
- .offset(x = indicatorOffsetX, y = (-BOTTOM_TAB_BAR_HEIGHT + 2).dp)
85
+ .offset { IntOffset((layout.insetLeft + slotWidth * (indicatorX.value + 0.5f) - layout.width / 2).roundToPx(), (-BOTTOM_TAB_BAR_HEIGHT + 2).dp.roundToPx()) }
86
86
  .height(2.dp)
87
87
  .width(44.dp)
88
88
  .background(
@@ -101,7 +101,11 @@ internal fun BottomTabBar(
101
101
  )
102
102
  )
103
103
  )
104
- floatingButton?.let { FloatingButton(it) }
104
+ floating?.let {
105
+ Box(Modifier.offset(x = (layout.insetLeft - layout.insetRight) / 2)) {
106
+ FloatingButton(it, slotWidth)
107
+ }
108
+ }
105
109
  }
106
110
  }
107
111
 
@@ -126,17 +130,10 @@ internal fun RowScope.RenderTabBarItem(
126
130
 
127
131
  @Composable
128
132
  internal fun TabBarItem(item: BottomTabItem, selected: Boolean, onClick: () -> Unit) {
129
- fun isNumber(label: String): Boolean {
130
- val numberRegex = "^\\d+$".toRegex()
131
- return numberRegex.matches(label)
132
- }
133
- fun formatLabel(label: String): String? {
134
- if (isNumber(label) && label.toInt() == 0) {
135
- return null
136
- }
137
- return label
138
- }
139
- val badgeLabel = item.badgeLabel?.let { formatLabel(it) }
133
+ // RN parity: "0" hides the badge, "" renders a dot.
134
+ val badgeLabel = item.badgeLabel?.takeUnless { it.toIntOrNull() == 0 }
135
+ val isNumber = badgeLabel?.toIntOrNull() != null
136
+ val showDot = badgeLabel?.isEmpty() ?: item.showDot
140
137
 
141
138
  Box(modifier = Modifier
142
139
  .fillMaxSize()
@@ -144,7 +141,7 @@ internal fun TabBarItem(item: BottomTabItem, selected: Boolean, onClick: () -> U
144
141
  .conditional(IsShowBaseLineDebug) {
145
142
  border(1.dp, Colors.blue_03)
146
143
  }
147
- .noFeedbackClickable {
144
+ .activeOpacityClickable {
148
145
  onClick()
149
146
  },
150
147
  contentAlignment = Alignment.BottomStart
@@ -152,18 +149,22 @@ internal fun TabBarItem(item: BottomTabItem, selected: Boolean, onClick: () -> U
152
149
  Column(
153
150
  modifier = Modifier
154
151
  .fillMaxSize()
155
- .padding(horizontal = Spacing.S, vertical = Spacing.S)
156
- .noFeedbackClickable {
157
- onClick()
158
- },
152
+ .padding(horizontal = Spacing.S, vertical = Spacing.S),
159
153
  horizontalAlignment = Alignment.CenterHorizontally,
160
154
  verticalArrangement = Arrangement.Center
161
155
  ) {
162
- Icon(
163
- source = item.icon,
164
- size = 28.dp,
165
- modifier = Modifier.padding(Spacing.XS),
166
- color = if (selected) AppTheme.current.colors.primary else AppTheme.current.colors.text.hint)
156
+ Box(modifier = Modifier.padding(Spacing.XS)) {
157
+ Icon(
158
+ source = item.icon,
159
+ size = 28.dp,
160
+ color = if (selected) AppTheme.current.colors.primary else AppTheme.current.colors.text.hint)
161
+ val badgeAnchor = Modifier.matchParentSize().wrapContentSize(Alignment.TopEnd, unbounded = true)
162
+ if (showDot) {
163
+ BadgeDot(size = DotSize.Small, modifier = badgeAnchor.offset(x = 4.dp, y = (-4).dp))
164
+ } else if (!badgeLabel.isNullOrEmpty()) {
165
+ Badge(badgeLabel, modifier = badgeAnchor.offset(x = if (isNumber) 8.dp else 12.dp, y = (-8).dp))
166
+ }
167
+ }
167
168
  Text(
168
169
  text = item.label,
169
170
  color = if (selected) AppTheme.current.colors.primary else AppTheme.current.colors.text.hint,
@@ -172,28 +173,19 @@ internal fun TabBarItem(item: BottomTabItem, selected: Boolean, onClick: () -> U
172
173
  overflow = TextOverflow.Ellipsis
173
174
  )
174
175
  }
175
- if (badgeLabel != null) {
176
- if (badgeLabel.isEmpty())
177
- BadgeDot(
178
- size = DotSize.Small,
179
- modifier = Modifier.offset(x = 50.dp, y = (-48).dp)
180
- )
181
- else
182
- Badge(badgeLabel, modifier = Modifier.offset(x = 44.dp, y = (-42).dp))
183
- }
184
176
  }
185
177
  }
186
178
 
187
179
  @Composable
188
- internal fun FloatingButton(data: BottomTabFloatingButton) {
180
+ internal fun FloatingButton(data: BottomTabFloatingButton, width: Dp) {
189
181
  Column(
190
182
  modifier = Modifier
191
- .width(floatingButtonWidth)
183
+ .width(width)
192
184
  .padding(horizontal = Spacing.XXS, vertical = Spacing.S)
193
185
  .conditional(IsShowBaseLineDebug) {
194
186
  border(1.dp, Colors.blue_03)
195
187
  }
196
- .noFeedbackClickable {
188
+ .activeOpacityClickable {
197
189
  data.onPress()
198
190
  },
199
191
  verticalArrangement = Arrangement.Bottom,
@@ -0,0 +1,97 @@
1
+ package vn.momo.kits.navigation.bottomtab
2
+
3
+ import androidx.compose.runtime.Composable
4
+ import androidx.compose.runtime.Stable
5
+ import androidx.compose.runtime.compositionLocalOf
6
+ import androidx.compose.runtime.getValue
7
+ import androidx.compose.runtime.mutableStateOf
8
+ import androidx.compose.runtime.remember
9
+ import androidx.compose.runtime.setValue
10
+ import androidx.compose.ui.unit.Dp
11
+ import vn.momo.kits.const.Spacing
12
+ import vn.momo.kits.layout.DuoMetrics
13
+ import vn.momo.kits.layout.ScreenLayout
14
+
15
+ // iPhone Duo: which side bar bar starts compressed when the strip cannot hold both whole.
16
+ enum class DuoBarCompressionBehavior { TOOLBAR, TAB_BAR }
17
+
18
+ private enum class DuoBar { TOOLBAR, TAB_BAR }
19
+
20
+ @Stable
21
+ internal class DuoBarCompression(
22
+ val toolbarCompressed: Boolean = false,
23
+ val tabBarCompressed: Boolean = false,
24
+ // the open rail needs the toolbar button slot too: only Back stays
25
+ val toolbarHidden: Boolean = false,
26
+ val expandToolbar: () -> Unit = {},
27
+ val expandTabBar: () -> Unit = {},
28
+ // a rail opened from the compressed state closes once a tab is picked
29
+ val onTabSelected: () -> Unit = {},
30
+ // side bar columns report their full height so the tab bar knows what is left
31
+ val setToolbarHeight: (Dp) -> Unit = {},
32
+ )
33
+
34
+ internal val LocalDuoBarCompression = compositionLocalOf { DuoBarCompression() }
35
+
36
+ // space kept between the top and the bottom groups (HIG Vertical controls)
37
+ internal val DUO_GROUP_GAP = Spacing.M
38
+ // Back, then the right toolkit: help + home capsule
39
+ private val DEFAULT_TOOLBAR_HEIGHT = DuoMetrics.controlSize + Spacing.M + DuoMetrics.itemPitch * 2
40
+ // Back, then the single button the toolbar folds into
41
+ private val COMPRESSED_TOOLBAR_HEIGHT = DuoMetrics.controlSize * 2 + Spacing.M
42
+
43
+ // Port of RN DuoBarCompression (HIG, Designing for iPhone Duo: Vertical controls):
44
+ // - both fit: both stay whole
45
+ // - one fits whole beside the other compressed: one is compressed, tapping it swaps them
46
+ // - otherwise both rest compressed; tapping one opens it for a moment (the rail closes
47
+ // again once a tab is picked)
48
+ @Composable
49
+ internal fun rememberDuoBarCompression(
50
+ behavior: DuoBarCompressionBehavior,
51
+ tabCount: Int,
52
+ hasQr: Boolean,
53
+ layout: ScreenLayout,
54
+ ): DuoBarCompression {
55
+ var toolbarHeight by remember { mutableStateOf(DEFAULT_TOOLBAR_HEIGHT) }
56
+ var expanded by remember(behavior) {
57
+ mutableStateOf(if (behavior == DuoBarCompressionBehavior.TOOLBAR) DuoBar.TAB_BAR else DuoBar.TOOLBAR)
58
+ }
59
+ var opened by remember { mutableStateOf<DuoBar?>(null) }
60
+ val setToolbarHeight = remember { { height: Dp -> toolbarHeight = height } }
61
+ val sideBar = layout.sideBar
62
+
63
+ return remember(sideBar, layout.height, tabCount, hasQr, toolbarHeight, expanded, opened) {
64
+ if (sideBar == null) return@remember DuoBarCompression()
65
+ val room = layout.height - sideBar.bottom - sideBar.top - DUO_GROUP_GAP
66
+ val tabBar = duoRailHeight(tabCount, hasQr)
67
+ val compressedTabBar = duoRailHeight(1, hasQr, compressed = true)
68
+ fun fits(toolbar: Dp, tabs: Dp) = toolbar + tabs <= room
69
+
70
+ when {
71
+ fits(toolbarHeight, tabBar) -> DuoBarCompression(setToolbarHeight = setToolbarHeight)
72
+ fits(COMPRESSED_TOOLBAR_HEIGHT, tabBar) && fits(toolbarHeight, compressedTabBar) -> DuoBarCompression(
73
+ toolbarCompressed = expanded == DuoBar.TAB_BAR,
74
+ tabBarCompressed = expanded == DuoBar.TOOLBAR,
75
+ expandToolbar = { expanded = DuoBar.TOOLBAR },
76
+ expandTabBar = { expanded = DuoBar.TAB_BAR },
77
+ setToolbarHeight = setToolbarHeight,
78
+ )
79
+ // an opened rail always shows, even when only the group gap is short: it closes once a tab is picked
80
+ opened == DuoBar.TAB_BAR -> DuoBarCompression(
81
+ toolbarCompressed = true,
82
+ toolbarHidden = true,
83
+ expandToolbar = { opened = DuoBar.TOOLBAR },
84
+ expandTabBar = { opened = DuoBar.TAB_BAR },
85
+ onTabSelected = { opened = null },
86
+ setToolbarHeight = setToolbarHeight,
87
+ )
88
+ else -> DuoBarCompression(
89
+ toolbarCompressed = opened != DuoBar.TOOLBAR,
90
+ tabBarCompressed = true,
91
+ expandToolbar = { opened = DuoBar.TOOLBAR },
92
+ expandTabBar = { opened = DuoBar.TAB_BAR },
93
+ setToolbarHeight = setToolbarHeight,
94
+ )
95
+ }
96
+ }
97
+ }