@momo-kits/native-kits 0.167.1-duo.2-debug → 0.167.1-duo.3-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/scheduled_tasks.lock +1 -0
- package/.claude/settings.local.json +185 -0
- package/CLAUDE.md +8 -3
- 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/commonMain/kotlin/vn/momo/kits/components/PopupNotify.kt +52 -25
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/PopupPromotion.kt +12 -2
- package/compose/src/commonMain/kotlin/vn/momo/kits/layout/ScreenLayout.kt +45 -15
- 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/StackScreen.kt +34 -8
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTab.kt +81 -62
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/DuoTabBar.kt +281 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/Header.kt +31 -4
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderBackground.kt +27 -2
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderRight.kt +17 -10
- package/compose/src/commonMain/kotlin/vn/momo/kits/platform/Platform.kt +3 -0
- package/compose/src/iosMain/kotlin/vn/momo/kits/platform/Platform.ios.kt +4 -0
- package/gradle.properties +1 -1
- package/ios/Application/HeaderRight.swift +25 -12
- package/ios/Application/Layout/ScreenLayout.swift +196 -0
- package/ios/Application/Navigation/BottomTab/BottomTab.swift +30 -9
- package/ios/Application/Navigation/BottomTab/BottomTabBar.swift +5 -2
- package/ios/Application/Navigation/BottomTab/DuoTabBar.swift +275 -0
- package/ios/Application/Navigation/HeaderUser.swift +4 -1
- package/ios/Application/Navigation/NavigationContainer.swift +3 -0
- package/ios/Application/Navigation/Overplay/BottomSheet.swift +41 -4
- package/ios/Application/Navigation/Overplay/ModalScreen.swift +5 -1
- package/ios/Application/Navigation/component/DuoScrollGradient.swift +55 -0
- package/ios/Application/Navigation/component/Header.swift +9 -3
- package/ios/Application/Navigation/component/HeaderBackground.swift +3 -2
- package/ios/Application/Navigation/component/NavigationFooter.swift +1 -1
- package/ios/Application/Navigation/component/NavigationHeaderRight.swift +61 -9
- package/ios/Application/Navigation/component/SideBar.swift +155 -0
- package/ios/Application/StackScreen.swift +135 -18
- package/ios/Popup/PopupDisplay.swift +84 -68
- package/ios/Popup/PopupLayout.swift +45 -0
- package/ios/Popup/PopupNotify.swift +43 -32
- package/ios/Popup/PopupPromotion.swift +17 -5
- package/ios/native-kits.podspec +1 -1
- package/ios-demo/MoMoUIKitsDemo.podspec +1 -1
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/DuoTabDemo.swift +76 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/Home.swift +1 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/IPhoneDuoDemo.swift +170 -0
- package/local.properties +8 -0
- package/package.json +1 -1
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
package vn.momo.kits.navigation.bottomtab
|
|
2
|
+
|
|
3
|
+
import androidx.compose.animation.core.Spring
|
|
4
|
+
import androidx.compose.animation.core.animateDpAsState
|
|
5
|
+
import androidx.compose.animation.core.spring
|
|
6
|
+
import androidx.compose.foundation.background
|
|
7
|
+
import androidx.compose.foundation.border
|
|
8
|
+
import androidx.compose.foundation.layout.Arrangement
|
|
9
|
+
import androidx.compose.foundation.layout.Box
|
|
10
|
+
import androidx.compose.foundation.layout.BoxScope
|
|
11
|
+
import androidx.compose.foundation.layout.BoxWithConstraints
|
|
12
|
+
import androidx.compose.foundation.layout.Column
|
|
13
|
+
import androidx.compose.foundation.layout.Row
|
|
14
|
+
import androidx.compose.foundation.layout.RowScope
|
|
15
|
+
import androidx.compose.foundation.layout.absolutePadding
|
|
16
|
+
import androidx.compose.foundation.layout.fillMaxHeight
|
|
17
|
+
import androidx.compose.foundation.layout.fillMaxSize
|
|
18
|
+
import androidx.compose.foundation.layout.height
|
|
19
|
+
import androidx.compose.foundation.layout.offset
|
|
20
|
+
import androidx.compose.foundation.layout.padding
|
|
21
|
+
import androidx.compose.foundation.layout.size
|
|
22
|
+
import androidx.compose.foundation.layout.width
|
|
23
|
+
import androidx.compose.foundation.shape.CircleShape
|
|
24
|
+
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
25
|
+
import androidx.compose.runtime.Composable
|
|
26
|
+
import androidx.compose.runtime.compositionLocalOf
|
|
27
|
+
import androidx.compose.runtime.getValue
|
|
28
|
+
import androidx.compose.ui.AbsoluteAlignment
|
|
29
|
+
import androidx.compose.ui.Alignment
|
|
30
|
+
import androidx.compose.ui.Modifier
|
|
31
|
+
import androidx.compose.ui.draw.clip
|
|
32
|
+
import androidx.compose.ui.draw.shadow
|
|
33
|
+
import androidx.compose.ui.graphics.Color
|
|
34
|
+
import androidx.compose.ui.graphics.Shape
|
|
35
|
+
import androidx.compose.ui.text.style.TextOverflow
|
|
36
|
+
import androidx.compose.ui.unit.Dp
|
|
37
|
+
import androidx.compose.ui.unit.dp
|
|
38
|
+
import vn.momo.kits.components.Badge
|
|
39
|
+
import vn.momo.kits.components.BadgeDot
|
|
40
|
+
import vn.momo.kits.components.DotSize
|
|
41
|
+
import vn.momo.kits.components.Icon
|
|
42
|
+
import vn.momo.kits.components.Text
|
|
43
|
+
import vn.momo.kits.const.AppTheme
|
|
44
|
+
import vn.momo.kits.const.Radius
|
|
45
|
+
import vn.momo.kits.const.Spacing
|
|
46
|
+
import vn.momo.kits.const.Typography
|
|
47
|
+
import vn.momo.kits.layout.DuoMetrics
|
|
48
|
+
import vn.momo.kits.layout.ScreenLayout
|
|
49
|
+
import vn.momo.kits.layout.SideBarEdge
|
|
50
|
+
import vn.momo.kits.modifier.noFeedbackClickable
|
|
51
|
+
import vn.momo.kits.modifier.setAutomationId
|
|
52
|
+
|
|
53
|
+
// Figma Fold-Design-System MT1/MT2, mirrored from RN DuoTabBar.tsx.
|
|
54
|
+
private val ITEM_SIZE = DuoMetrics.controlSize
|
|
55
|
+
private val ICON_SIZE = 28.dp
|
|
56
|
+
private val QR_ICON_SIZE = 24.dp
|
|
57
|
+
private val RAIL_PADDING = 6.dp
|
|
58
|
+
private val RAIL_GAP = Spacing.XS
|
|
59
|
+
// selection bubble overflows the 48 rail cell by 5 above and below
|
|
60
|
+
private val RAIL_BUBBLE_WIDTH = 44.dp
|
|
61
|
+
private val RAIL_BUBBLE_HEIGHT = 58.dp
|
|
62
|
+
private val FLOATING_HEIGHT = 62.dp
|
|
63
|
+
private val FLOATING_PADDING = Spacing.XS
|
|
64
|
+
private val FLOATING_QR_SIZE = 44.dp
|
|
65
|
+
// floating bar spans 6 of the 8 portrait grid columns, like the content
|
|
66
|
+
private const val GRID_COLUMNS = 8
|
|
67
|
+
private const val GRID_SPAN = 6
|
|
68
|
+
// RN reanimated spring { damping: 18, stiffness: 220, mass: 0.8 }
|
|
69
|
+
private val BUBBLE_SPRING = spring<Dp>(dampingRatio = 0.68f, stiffness = 275f, visibilityThreshold = Dp(0.5f))
|
|
70
|
+
|
|
71
|
+
// Space from the window bottom the rail stack takes, so the header side bar stays above it.
|
|
72
|
+
internal val LocalDuoRailHeight = compositionLocalOf { 0.dp }
|
|
73
|
+
|
|
74
|
+
internal fun duoRailHeight(itemCount: Int, hasQr: Boolean): Dp =
|
|
75
|
+
Spacing.XL + RAIL_PADDING * 2 + ITEM_SIZE * itemCount + RAIL_GAP * (itemCount - 1).coerceAtLeast(0) +
|
|
76
|
+
if (hasQr) Spacing.M + ITEM_SIZE else 0.dp
|
|
77
|
+
|
|
78
|
+
// Space tab content leaves under the floating bar; 0 when the rail sits in the side strip or off Duo.
|
|
79
|
+
internal fun duoFloatingTabBarInset(layout: ScreenLayout): Dp =
|
|
80
|
+
if (layout.isIPhoneDuo && layout.sideBar == null) FLOATING_HEIGHT + Spacing.M + layout.insetBottom else 0.dp
|
|
81
|
+
|
|
82
|
+
// iPhone Duo tab bar: glass rail in the side bar, or a floating glass bar when unfolded portrait.
|
|
83
|
+
@Composable
|
|
84
|
+
internal fun BoxScope.DuoTabBar(
|
|
85
|
+
layout: ScreenLayout,
|
|
86
|
+
items: List<BottomTabItem>,
|
|
87
|
+
floatingButton: BottomTabFloatingButton?,
|
|
88
|
+
selectedIndex: Int,
|
|
89
|
+
onTabSelected: (Int) -> Unit,
|
|
90
|
+
) {
|
|
91
|
+
if (layout.sideBar != null) {
|
|
92
|
+
DuoRailTabBar(layout, items, floatingButton, selectedIndex, onTabSelected)
|
|
93
|
+
} else {
|
|
94
|
+
DuoFloatingTabBar(layout, items, floatingButton, selectedIndex, onTabSelected)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
@Composable
|
|
99
|
+
private fun BoxScope.DuoRailTabBar(
|
|
100
|
+
layout: ScreenLayout,
|
|
101
|
+
items: List<BottomTabItem>,
|
|
102
|
+
floatingButton: BottomTabFloatingButton?,
|
|
103
|
+
selectedIndex: Int,
|
|
104
|
+
onTabSelected: (Int) -> Unit,
|
|
105
|
+
) {
|
|
106
|
+
val sideBar = layout.sideBar ?: return
|
|
107
|
+
val isRight = sideBar.edge == SideBarEdge.RIGHT
|
|
108
|
+
val bubbleOffset by animateDpAsState(
|
|
109
|
+
targetValue = (ITEM_SIZE + RAIL_GAP) * selectedIndex.coerceAtLeast(0),
|
|
110
|
+
animationSpec = BUBBLE_SPRING,
|
|
111
|
+
label = "DuoRailBubble",
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
Column(
|
|
115
|
+
modifier = Modifier
|
|
116
|
+
.align(if (isRight) AbsoluteAlignment.BottomRight else AbsoluteAlignment.BottomLeft)
|
|
117
|
+
.width(sideBar.width)
|
|
118
|
+
.padding(bottom = Spacing.XL)
|
|
119
|
+
.absolutePadding(left = if (isRight) Spacing.M else 0.dp, right = if (isRight) 0.dp else Spacing.M),
|
|
120
|
+
horizontalAlignment = if (isRight) AbsoluteAlignment.Left else AbsoluteAlignment.Right,
|
|
121
|
+
verticalArrangement = Arrangement.spacedBy(Spacing.M),
|
|
122
|
+
) {
|
|
123
|
+
Box(
|
|
124
|
+
Modifier
|
|
125
|
+
.width(ITEM_SIZE)
|
|
126
|
+
.tabBarMaterial(RoundedCornerShape(Radius.XL))
|
|
127
|
+
.setAutomationId("bottom_tab_bar")
|
|
128
|
+
) {
|
|
129
|
+
Box(
|
|
130
|
+
Modifier
|
|
131
|
+
.offset(
|
|
132
|
+
x = (ITEM_SIZE - RAIL_BUBBLE_WIDTH) / 2,
|
|
133
|
+
y = RAIL_PADDING - (RAIL_BUBBLE_HEIGHT - ITEM_SIZE) / 2 + bubbleOffset,
|
|
134
|
+
)
|
|
135
|
+
.size(RAIL_BUBBLE_WIDTH, RAIL_BUBBLE_HEIGHT)
|
|
136
|
+
.background(AppTheme.current.colors.background.selected, CircleShape)
|
|
137
|
+
)
|
|
138
|
+
Column(
|
|
139
|
+
modifier = Modifier.padding(vertical = RAIL_PADDING),
|
|
140
|
+
verticalArrangement = Arrangement.spacedBy(RAIL_GAP),
|
|
141
|
+
) {
|
|
142
|
+
items.forEachIndexed { index, item ->
|
|
143
|
+
Box(
|
|
144
|
+
modifier = Modifier
|
|
145
|
+
.size(ITEM_SIZE)
|
|
146
|
+
.noFeedbackClickable { onTabSelected(index) },
|
|
147
|
+
contentAlignment = Alignment.Center,
|
|
148
|
+
) {
|
|
149
|
+
DuoTabIcon(item, selected = index == selectedIndex)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (floatingButton != null) {
|
|
155
|
+
QrButton(floatingButton, size = ITEM_SIZE)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
@Composable
|
|
161
|
+
private fun BoxScope.DuoFloatingTabBar(
|
|
162
|
+
layout: ScreenLayout,
|
|
163
|
+
items: List<BottomTabItem>,
|
|
164
|
+
floatingButton: BottomTabFloatingButton?,
|
|
165
|
+
selectedIndex: Int,
|
|
166
|
+
onTabSelected: (Int) -> Unit,
|
|
167
|
+
) {
|
|
168
|
+
val columnWidth = (layout.width - Spacing.M * 2 - Spacing.M * (GRID_COLUMNS - 1)) / GRID_COLUMNS
|
|
169
|
+
val barWidth = columnWidth * GRID_SPAN + Spacing.M * (GRID_SPAN - 1)
|
|
170
|
+
val useQr = floatingButton != null && items.size % 2 == 0
|
|
171
|
+
val middle = items.size / 2
|
|
172
|
+
val cellCount = items.size + if (useQr) 1 else 0
|
|
173
|
+
val visualIndex = if (useQr && selectedIndex >= middle) selectedIndex + 1 else selectedIndex
|
|
174
|
+
|
|
175
|
+
Box(
|
|
176
|
+
modifier = Modifier
|
|
177
|
+
.align(Alignment.BottomCenter)
|
|
178
|
+
.padding(bottom = layout.insetBottom + Spacing.M)
|
|
179
|
+
.width(barWidth)
|
|
180
|
+
.height(FLOATING_HEIGHT)
|
|
181
|
+
.tabBarMaterial(RoundedCornerShape(FLOATING_HEIGHT / 2))
|
|
182
|
+
.padding(FLOATING_PADDING)
|
|
183
|
+
.setAutomationId("bottom_tab_bar")
|
|
184
|
+
) {
|
|
185
|
+
BoxWithConstraints(Modifier.fillMaxSize()) {
|
|
186
|
+
val cellWidth = maxWidth / cellCount
|
|
187
|
+
val bubbleOffset by animateDpAsState(
|
|
188
|
+
targetValue = cellWidth * visualIndex.coerceAtLeast(0),
|
|
189
|
+
animationSpec = BUBBLE_SPRING,
|
|
190
|
+
label = "DuoFloatingBubble",
|
|
191
|
+
)
|
|
192
|
+
Box(
|
|
193
|
+
Modifier
|
|
194
|
+
.offset(x = bubbleOffset)
|
|
195
|
+
.width(cellWidth)
|
|
196
|
+
.fillMaxHeight()
|
|
197
|
+
.background(AppTheme.current.colors.background.selected, CircleShape)
|
|
198
|
+
)
|
|
199
|
+
Row(Modifier.fillMaxSize()) {
|
|
200
|
+
items.forEachIndexed { index, item ->
|
|
201
|
+
if (useQr && index == middle) {
|
|
202
|
+
Box(Modifier.weight(1f).fillMaxHeight(), contentAlignment = Alignment.Center) {
|
|
203
|
+
QrButton(floatingButton, size = FLOATING_QR_SIZE)
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
FloatingCell(item, selected = index == selectedIndex) { onTabSelected(index) }
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
@Composable
|
|
214
|
+
private fun RowScope.FloatingCell(item: BottomTabItem, selected: Boolean, onClick: () -> Unit) {
|
|
215
|
+
val colors = AppTheme.current.colors
|
|
216
|
+
Column(
|
|
217
|
+
modifier = Modifier
|
|
218
|
+
.weight(1f)
|
|
219
|
+
.fillMaxHeight()
|
|
220
|
+
.noFeedbackClickable(onClick = onClick),
|
|
221
|
+
horizontalAlignment = Alignment.CenterHorizontally,
|
|
222
|
+
verticalArrangement = Arrangement.spacedBy(Spacing.XXS, Alignment.CenterVertically),
|
|
223
|
+
) {
|
|
224
|
+
DuoTabIcon(item, selected)
|
|
225
|
+
Text(
|
|
226
|
+
text = item.label,
|
|
227
|
+
style = Typography.labelXsMedium,
|
|
228
|
+
color = if (selected) colors.primary else colors.text.default,
|
|
229
|
+
maxLines = 1,
|
|
230
|
+
overflow = TextOverflow.Ellipsis,
|
|
231
|
+
)
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
@Composable
|
|
236
|
+
private fun DuoTabIcon(item: BottomTabItem, selected: Boolean) {
|
|
237
|
+
val colors = AppTheme.current.colors
|
|
238
|
+
val badgeLabel = item.badgeLabel?.takeUnless { it.toIntOrNull() == 0 }
|
|
239
|
+
Box {
|
|
240
|
+
Icon(
|
|
241
|
+
source = item.icon,
|
|
242
|
+
size = ICON_SIZE,
|
|
243
|
+
color = if (selected) colors.primary else colors.text.default,
|
|
244
|
+
)
|
|
245
|
+
when {
|
|
246
|
+
badgeLabel == null && !item.showDot -> Unit
|
|
247
|
+
badgeLabel.isNullOrEmpty() -> BadgeDot(
|
|
248
|
+
size = DotSize.Small,
|
|
249
|
+
modifier = Modifier.align(Alignment.TopEnd),
|
|
250
|
+
)
|
|
251
|
+
else -> Badge(
|
|
252
|
+
badgeLabel,
|
|
253
|
+
modifier = Modifier.align(Alignment.TopStart).offset(x = ICON_SIZE / 2, y = -Spacing.XS),
|
|
254
|
+
)
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
@Composable
|
|
260
|
+
private fun QrButton(data: BottomTabFloatingButton, size: Dp) {
|
|
261
|
+
Box(
|
|
262
|
+
modifier = Modifier
|
|
263
|
+
.size(size)
|
|
264
|
+
.clip(CircleShape)
|
|
265
|
+
.background(AppTheme.current.colors.primary)
|
|
266
|
+
.noFeedbackClickable(onClick = data.onPress)
|
|
267
|
+
.setAutomationId(data.label),
|
|
268
|
+
contentAlignment = Alignment.Center,
|
|
269
|
+
) {
|
|
270
|
+
Icon(source = data.icon, color = Color.White, size = QR_ICON_SIZE)
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// RN falls back to this when native glass is missing; Compose has no backdrop blur, so it always does.
|
|
275
|
+
@Composable
|
|
276
|
+
private fun Modifier.tabBarMaterial(shape: Shape): Modifier {
|
|
277
|
+
val colors = AppTheme.current.colors
|
|
278
|
+
return shadow(3.dp, shape)
|
|
279
|
+
.background(colors.background.surface)
|
|
280
|
+
.border(1.dp, colors.border.default, shape)
|
|
281
|
+
}
|
|
@@ -22,6 +22,8 @@ import androidx.compose.runtime.getValue
|
|
|
22
22
|
import androidx.compose.ui.Alignment
|
|
23
23
|
import androidx.compose.ui.Modifier
|
|
24
24
|
import androidx.compose.ui.draw.clipToBounds
|
|
25
|
+
import androidx.compose.ui.draw.drawBehind
|
|
26
|
+
import androidx.compose.ui.geometry.Offset
|
|
25
27
|
import androidx.compose.ui.graphics.graphicsLayer
|
|
26
28
|
import androidx.compose.ui.graphics.Brush
|
|
27
29
|
import androidx.compose.ui.graphics.Color
|
|
@@ -47,7 +49,7 @@ import vn.momo.kits.navigation.LocalNavigator
|
|
|
47
49
|
import vn.momo.kits.navigation.LocalOptions
|
|
48
50
|
import vn.momo.kits.navigation.LocalScrollState
|
|
49
51
|
import vn.momo.kits.navigation.getInputSearchType
|
|
50
|
-
import vn.momo.kits.
|
|
52
|
+
import vn.momo.kits.navigation.usesDuoGlassHeader
|
|
51
53
|
import vn.momo.kits.utils.horizontalSafeAreaPadding
|
|
52
54
|
|
|
53
55
|
@InternalApi
|
|
@@ -81,7 +83,30 @@ internal fun headerOpacity(): Float {
|
|
|
81
83
|
internal fun headerTitleMaxWidth(): Dp {
|
|
82
84
|
val layout = getScreenLayout()
|
|
83
85
|
if (layout.sideBar != null) return layout.contentWidth - Spacing.M * 2
|
|
84
|
-
return
|
|
86
|
+
return layout.width - HEADER_ACTIONS_WIDTH
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Scroll distance over which the Duo glass bar fades in.
|
|
90
|
+
private val DUO_GLASS_FADE_DISTANCE = 16.dp
|
|
91
|
+
|
|
92
|
+
// RN DuoGlassBackground fallback: translucent surface with a hairline once content scrolls under it.
|
|
93
|
+
@Suppress("FrequentlyChangingValue")
|
|
94
|
+
@Composable
|
|
95
|
+
private fun Modifier.duoGlassBar(): Modifier {
|
|
96
|
+
val scrollState = LocalScrollState.current
|
|
97
|
+
val fadePx = with(LocalDensity.current) { DUO_GLASS_FADE_DISTANCE.toPx() }
|
|
98
|
+
val alpha by animateFloatAsState((scrollState.value / fadePx).coerceIn(0f, 1f), label = "DuoGlassAlpha")
|
|
99
|
+
val colors = AppTheme.current.colors
|
|
100
|
+
return background(colors.background.surface.copy(alpha = 0.8f * alpha))
|
|
101
|
+
.drawBehind {
|
|
102
|
+
val stroke = 0.5.dp.toPx()
|
|
103
|
+
drawLine(
|
|
104
|
+
color = colors.border.default.copy(alpha = alpha),
|
|
105
|
+
start = Offset(0f, size.height - stroke / 2),
|
|
106
|
+
end = Offset(size.width, size.height - stroke / 2),
|
|
107
|
+
strokeWidth = stroke,
|
|
108
|
+
)
|
|
109
|
+
}
|
|
85
110
|
}
|
|
86
111
|
|
|
87
112
|
@Composable
|
|
@@ -111,13 +136,15 @@ internal fun Header(onBackHandler: (() -> Unit)? = null) {
|
|
|
111
136
|
// Centered title needs a full-width overlay to stay screen-centered, not boxed between back button and header right.
|
|
112
137
|
val centerTitle = titlePosition == TitlePosition.CENTER && options.headerTitle is HeaderTitle.Default
|
|
113
138
|
|
|
139
|
+
val useDuoGlass = options.usesDuoGlassHeader(layout)
|
|
114
140
|
val headerImage = AppTheme.current.assets.headerBackground
|
|
115
|
-
.takeUnless { options.headerType is HeaderType.Transparent }
|
|
141
|
+
.takeUnless { options.headerType is HeaderType.Transparent || useDuoGlass }
|
|
116
142
|
|
|
117
143
|
Box(
|
|
118
144
|
Modifier.height(layout.headerHeight)
|
|
119
145
|
.fillMaxWidth()
|
|
120
|
-
.
|
|
146
|
+
.then(if (useDuoGlass) Modifier.duoGlassBar() else Modifier)
|
|
147
|
+
.conditional(headerImage.isNullOrEmpty() && !useDuoGlass) {
|
|
121
148
|
background(background.copy(alpha = animatedAlpha))
|
|
122
149
|
}
|
|
123
150
|
.conditional(IsShowBaseLineDebug) {
|
|
@@ -9,6 +9,8 @@ import androidx.compose.foundation.layout.height
|
|
|
9
9
|
import androidx.compose.runtime.Composable
|
|
10
10
|
import androidx.compose.runtime.getValue
|
|
11
11
|
import androidx.compose.ui.Modifier
|
|
12
|
+
import androidx.compose.ui.draw.clipToBounds
|
|
13
|
+
import androidx.compose.ui.graphics.graphicsLayer
|
|
12
14
|
import androidx.compose.ui.geometry.Offset
|
|
13
15
|
import androidx.compose.ui.graphics.Brush
|
|
14
16
|
import androidx.compose.ui.graphics.Color
|
|
@@ -20,6 +22,7 @@ import vn.momo.kits.layout.getScreenLayout
|
|
|
20
22
|
import vn.momo.kits.modifier.conditional
|
|
21
23
|
import vn.momo.kits.navigation.LocalOptions
|
|
22
24
|
import vn.momo.kits.navigation.LocalScrollState
|
|
25
|
+
import vn.momo.kits.navigation.usesDuoGlassHeader
|
|
23
26
|
|
|
24
27
|
enum class AnimatedHeaderRatio(val value: Float){
|
|
25
28
|
RATIO_16_9(16f / 9f),
|
|
@@ -29,6 +32,10 @@ enum class AnimatedHeaderRatio(val value: Float){
|
|
|
29
32
|
|
|
30
33
|
internal val HEADER_BACKGROUND_HEIGHT = 154.dp
|
|
31
34
|
|
|
35
|
+
// RN DuoScrollGradient: extended gradient runs this far below the header and collapses over this scroll
|
|
36
|
+
private val DUO_GRADIENT_EXTRA = 52.dp
|
|
37
|
+
private val DUO_COLLAPSE_DISTANCE = 100.dp
|
|
38
|
+
|
|
32
39
|
@Suppress("FrequentlyChangingValue")
|
|
33
40
|
@Composable
|
|
34
41
|
internal fun HeaderBackground() {
|
|
@@ -38,7 +45,8 @@ internal fun HeaderBackground() {
|
|
|
38
45
|
val scrollState = LocalScrollState.current
|
|
39
46
|
val backgroundColor = options.backgroundColor ?: AppTheme.current.colors.background.default
|
|
40
47
|
|
|
41
|
-
val
|
|
48
|
+
val layout = getScreenLayout()
|
|
49
|
+
val minHeight = layout.headerHeight
|
|
42
50
|
val maxHeight = HEADER_BACKGROUND_HEIGHT
|
|
43
51
|
val opacity by animateFloatAsState(
|
|
44
52
|
targetValue = (1 - (scrollState.value * 1f / HEADER_HEIGHT * 1f)).coerceIn(0f, 1f),
|
|
@@ -57,7 +65,24 @@ internal fun HeaderBackground() {
|
|
|
57
65
|
else -> 0.dp
|
|
58
66
|
}
|
|
59
67
|
|
|
60
|
-
|
|
68
|
+
// iPhone Duo glass header: default gradient scrolls away, extended collapses to the header and fades
|
|
69
|
+
val useDuoGlass = options.usesDuoGlassHeader(layout)
|
|
70
|
+
val scrollPx = scrollState.value.toFloat()
|
|
71
|
+
val duoModifier = when {
|
|
72
|
+
!useDuoGlass -> Modifier.height(height)
|
|
73
|
+
options.headerType is HeaderType.Extended -> {
|
|
74
|
+
val collapse = with(density) {
|
|
75
|
+
(scrollPx / minOf(DUO_COLLAPSE_DISTANCE, minHeight).toPx()).coerceIn(0f, 1f)
|
|
76
|
+
}
|
|
77
|
+
val fade = with(density) { (scrollPx / DUO_GRADIENT_EXTRA.toPx()).coerceIn(0f, 1f) }
|
|
78
|
+
Modifier
|
|
79
|
+
.height(minHeight + DUO_GRADIENT_EXTRA * (1f - collapse))
|
|
80
|
+
.graphicsLayer { alpha = 1f - fade }
|
|
81
|
+
}
|
|
82
|
+
else -> Modifier.height(minHeight).graphicsLayer { translationY = -scrollPx }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
Box(Modifier.fillMaxWidth().then(duoModifier).clipToBounds().background(backgroundColor)) {
|
|
61
86
|
Box(Modifier
|
|
62
87
|
.height(maxHeight)
|
|
63
88
|
.fillMaxWidth()
|
|
@@ -60,6 +60,8 @@ fun HeaderRight(
|
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
private const val HELP_CENTER_APP_ID = "vn.momo.helpcenter"
|
|
64
|
+
|
|
63
65
|
@Composable
|
|
64
66
|
internal fun Toolkit(
|
|
65
67
|
headerRight: HeaderRight.Toolkit,
|
|
@@ -118,10 +120,13 @@ internal fun Toolkit(
|
|
|
118
120
|
) {}
|
|
119
121
|
}
|
|
120
122
|
|
|
121
|
-
fun
|
|
122
|
-
api?.dismissAll {
|
|
123
|
+
fun onPressClose() {
|
|
124
|
+
if (headerRight.useCloseIcon) api?.dismiss { } else api?.dismissAll { }
|
|
123
125
|
}
|
|
124
126
|
|
|
127
|
+
val useHelpCenter = context != null && context.appId != HELP_CENTER_APP_ID
|
|
128
|
+
val closeIcon = if (headerRight.useCloseIcon) "16_navigation_close_circle" else "16_basic_home"
|
|
129
|
+
|
|
125
130
|
val navButtonConfig = getNavigationButtonConfig(
|
|
126
131
|
headerRight,
|
|
127
132
|
::onPressShortcut,
|
|
@@ -157,8 +162,8 @@ internal fun Toolkit(
|
|
|
157
162
|
onClick = navButtonConfig.onPress,
|
|
158
163
|
)
|
|
159
164
|
}
|
|
160
|
-
SideBarGroup(headerColor = headerColor, itemCount = if (
|
|
161
|
-
if (
|
|
165
|
+
SideBarGroup(headerColor = headerColor, itemCount = if (useHelpCenter) 2 else 1) {
|
|
166
|
+
if (useHelpCenter) {
|
|
162
167
|
SideBarGroupItem(
|
|
163
168
|
icon = "help_center",
|
|
164
169
|
headerColor = headerColor,
|
|
@@ -167,10 +172,10 @@ internal fun Toolkit(
|
|
|
167
172
|
)
|
|
168
173
|
}
|
|
169
174
|
SideBarGroupItem(
|
|
170
|
-
icon =
|
|
175
|
+
icon = closeIcon,
|
|
171
176
|
headerColor = headerColor,
|
|
172
177
|
automationId = "btn_navigation_close",
|
|
173
|
-
onClick = ::
|
|
178
|
+
onClick = ::onPressClose,
|
|
174
179
|
)
|
|
175
180
|
}
|
|
176
181
|
}
|
|
@@ -200,7 +205,7 @@ internal fun Toolkit(
|
|
|
200
205
|
.clip(shape = RoundedCornerShape(14.dp))
|
|
201
206
|
.background(headerColor.backgroundButton)
|
|
202
207
|
) {
|
|
203
|
-
if (
|
|
208
|
+
if (useHelpCenter) {
|
|
204
209
|
Icon(
|
|
205
210
|
source = "help_center",
|
|
206
211
|
size = 20.dp,
|
|
@@ -215,10 +220,10 @@ internal fun Toolkit(
|
|
|
215
220
|
)
|
|
216
221
|
}
|
|
217
222
|
Icon(
|
|
218
|
-
source =
|
|
223
|
+
source = closeIcon,
|
|
219
224
|
size = 20.dp,
|
|
220
225
|
color = headerColor.tintIconColor,
|
|
221
|
-
modifier = Modifier.padding(4.dp).activeOpacityClickable(onClick = ::
|
|
226
|
+
modifier = Modifier.padding(4.dp).activeOpacityClickable(onClick = ::onPressClose)
|
|
222
227
|
)
|
|
223
228
|
}
|
|
224
229
|
}
|
|
@@ -308,7 +313,9 @@ sealed interface HeaderRight {
|
|
|
308
313
|
val useMore: Boolean = false,
|
|
309
314
|
val useSystemTools: Boolean = true,
|
|
310
315
|
val tools: List<ToolGroup> = emptyList(),
|
|
311
|
-
val toolCallback: ((String) -> Unit)? = { _ -> }
|
|
316
|
+
val toolCallback: ((String) -> Unit)? = { _ -> },
|
|
317
|
+
// Close dismisses only this mini app instead of returning home.
|
|
318
|
+
val useCloseIcon: Boolean = false,
|
|
312
319
|
) : HeaderRight
|
|
313
320
|
}
|
|
314
321
|
|
|
@@ -14,6 +14,9 @@ data class ScreenDimension(
|
|
|
14
14
|
|
|
15
15
|
expect fun getPlatformName(): String
|
|
16
16
|
|
|
17
|
+
// iPhone idiom only; iPad windows and Android never get the iPhone / iPhone Duo layout.
|
|
18
|
+
expect fun isPhoneIdiom(): Boolean
|
|
19
|
+
|
|
17
20
|
@Composable
|
|
18
21
|
expect fun getScreenDimensions(): ScreenDimension
|
|
19
22
|
|
|
@@ -39,11 +39,15 @@ import platform.Foundation.NSBundle
|
|
|
39
39
|
import platform.UIKit.UIColor
|
|
40
40
|
import platform.UIKit.UIDevice
|
|
41
41
|
import platform.UIKit.UIScreen
|
|
42
|
+
import platform.UIKit.UIUserInterfaceIdiomPhone
|
|
42
43
|
import vn.momo.kits.navigation.AttachHostBackGesture
|
|
43
44
|
import vn.momo.kits.navigation.Navigator
|
|
44
45
|
|
|
45
46
|
actual fun getPlatformName(): String = "iOS"
|
|
46
47
|
|
|
48
|
+
actual fun isPhoneIdiom(): Boolean =
|
|
49
|
+
UIDevice.currentDevice.userInterfaceIdiom == UIUserInterfaceIdiomPhone
|
|
50
|
+
|
|
47
51
|
@OptIn(ExperimentalForeignApi::class)
|
|
48
52
|
@Composable
|
|
49
53
|
actual fun getScreenDimensions(): ScreenDimension = memScoped {
|
package/gradle.properties
CHANGED
|
@@ -7,20 +7,31 @@ public struct HeaderRightData {
|
|
|
7
7
|
var tools: [ToolGroup]
|
|
8
8
|
var toolCallback: ((String) -> Void)?
|
|
9
9
|
var useSystemTools: Bool
|
|
10
|
+
var useCloseIcon: Bool
|
|
10
11
|
|
|
11
12
|
public init(
|
|
12
13
|
useShortcut: Bool = false,
|
|
13
14
|
useMore: Bool = false,
|
|
14
15
|
tools: [ToolGroup] = [],
|
|
15
16
|
toolCallback: ((String) -> Void)? = nil,
|
|
16
|
-
useSystemTools: Bool = true
|
|
17
|
+
useSystemTools: Bool = true,
|
|
18
|
+
useCloseIcon: Bool = false
|
|
17
19
|
) {
|
|
18
20
|
self.useShortcut = useShortcut
|
|
19
21
|
self.useMore = useMore
|
|
20
22
|
self.tools = tools
|
|
21
23
|
self.toolCallback = toolCallback
|
|
22
24
|
self.useSystemTools = useSystemTools
|
|
25
|
+
self.useCloseIcon = useCloseIcon
|
|
23
26
|
}
|
|
27
|
+
|
|
28
|
+
/// Close-circle instead of home, as RN `headerRight.useCloseIcon`.
|
|
29
|
+
var closeIcon: String { useCloseIcon ? "16_navigation_close_circle" : "16_basic_home" }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/// The Help Center mini-app hides its own help-center button.
|
|
33
|
+
func showsHelpCenter(_ context: MiniAppContext?) -> Bool {
|
|
34
|
+
context?.appId != "vn.momo.helpcenter"
|
|
24
35
|
}
|
|
25
36
|
|
|
26
37
|
public struct Tool {
|
|
@@ -238,17 +249,19 @@ struct ToolkitHeaderRight: View {
|
|
|
238
249
|
}
|
|
239
250
|
|
|
240
251
|
HStack(alignment: .center, spacing: 0) {
|
|
241
|
-
|
|
242
|
-
.
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
+
if showsHelpCenter(environment.applicationContext) {
|
|
253
|
+
Icon(source: "help_center", size: 20, color: tintColor ?? Colors.black17)
|
|
254
|
+
.padding(4)
|
|
255
|
+
.onTapGesture {
|
|
256
|
+
onPressHelpCenter()
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
Rectangle()
|
|
260
|
+
.fill(tintColor ?? Colors.black20)
|
|
261
|
+
.frame(width: 0.5, height: 12)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
Icon(source: headerRight?.closeIcon ?? "16_basic_home", size: 20, color: tintColor ?? Colors.black17)
|
|
252
265
|
.padding(4)
|
|
253
266
|
.onTapGesture {
|
|
254
267
|
onPressClose()
|