@momo-kits/native-kits 0.168.1-beta.24-debug → 0.168.1-beta.25-debug
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +1 -0
- package/compose/build.gradle.kts +1 -1
- package/compose/compose.podspec +1 -1
- package/compose/src/androidMain/kotlin/vn/momo/kits/platform/ReduceMotion.android.kt +17 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTab.kt +4 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTabBar.kt +34 -14
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/DuoLiquidSelection.kt +380 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/DuoTabBar.kt +149 -90
- package/compose/src/commonMain/kotlin/vn/momo/kits/platform/ReduceMotion.kt +7 -0
- package/compose/src/iosMain/kotlin/vn/momo/kits/platform/ReduceMotion.ios.kt +48 -0
- package/gradle.properties +1 -1
- package/ios/Navigation/BottomTab/BottomTab.swift +16 -3
- package/ios/Navigation/BottomTab/BottomTabBar.swift +47 -29
- package/ios/Navigation/BottomTab/DuoLiquidSelection.swift +364 -0
- package/ios/Navigation/BottomTab/DuoTabBar.swift +156 -69
- package/ios/native-kits.podspec +1 -1
- package/ios-demo/MoMoUIKitsDemo.podspec +1 -1
- package/package.json +1 -1
- package/local.properties +0 -8
|
@@ -1,21 +1,15 @@
|
|
|
1
1
|
package vn.momo.kits.navigation.bottomtab
|
|
2
2
|
|
|
3
|
-
import androidx.compose.animation.core.Spring
|
|
4
|
-
import androidx.compose.animation.core.animateDpAsState
|
|
5
|
-
import androidx.compose.animation.core.animateFloatAsState
|
|
6
|
-
import androidx.compose.animation.core.spring
|
|
7
3
|
import androidx.compose.foundation.background
|
|
4
|
+
import androidx.compose.foundation.border
|
|
8
5
|
import androidx.compose.foundation.clickable
|
|
6
|
+
import androidx.compose.foundation.combinedClickable
|
|
9
7
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
10
|
-
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
|
11
|
-
import androidx.compose.foundation.border
|
|
12
8
|
import androidx.compose.foundation.layout.Arrangement
|
|
13
9
|
import androidx.compose.foundation.layout.Box
|
|
14
10
|
import androidx.compose.foundation.layout.BoxScope
|
|
15
|
-
import androidx.compose.foundation.layout.BoxWithConstraints
|
|
16
11
|
import androidx.compose.foundation.layout.Column
|
|
17
12
|
import androidx.compose.foundation.layout.Row
|
|
18
|
-
import androidx.compose.foundation.layout.RowScope
|
|
19
13
|
import androidx.compose.foundation.layout.absolutePadding
|
|
20
14
|
import androidx.compose.foundation.layout.fillMaxHeight
|
|
21
15
|
import androidx.compose.foundation.layout.fillMaxSize
|
|
@@ -25,25 +19,30 @@ import androidx.compose.foundation.layout.offset
|
|
|
25
19
|
import androidx.compose.foundation.layout.padding
|
|
26
20
|
import androidx.compose.foundation.layout.size
|
|
27
21
|
import androidx.compose.foundation.layout.width
|
|
22
|
+
import androidx.compose.foundation.layout.widthIn
|
|
23
|
+
import androidx.compose.foundation.layout.wrapContentSize
|
|
28
24
|
import androidx.compose.foundation.shape.CircleShape
|
|
29
25
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
30
26
|
import androidx.compose.runtime.Composable
|
|
31
27
|
import androidx.compose.runtime.CompositionLocalProvider
|
|
32
|
-
import androidx.compose.runtime.LaunchedEffect
|
|
33
|
-
import androidx.compose.runtime.MutableState
|
|
34
28
|
import androidx.compose.runtime.compositionLocalOf
|
|
35
29
|
import androidx.compose.runtime.getValue
|
|
36
30
|
import androidx.compose.runtime.mutableStateOf
|
|
37
31
|
import androidx.compose.runtime.remember
|
|
32
|
+
import androidx.compose.runtime.setValue
|
|
38
33
|
import androidx.compose.ui.AbsoluteAlignment
|
|
39
34
|
import androidx.compose.ui.Alignment
|
|
40
35
|
import androidx.compose.ui.Modifier
|
|
36
|
+
import androidx.compose.ui.composed
|
|
41
37
|
import androidx.compose.ui.draw.clip
|
|
42
38
|
import androidx.compose.ui.draw.shadow
|
|
43
|
-
import androidx.compose.ui.graphics.graphicsLayer
|
|
44
39
|
import androidx.compose.ui.graphics.Color
|
|
45
40
|
import androidx.compose.ui.graphics.Shape
|
|
41
|
+
import androidx.compose.ui.graphics.graphicsLayer
|
|
42
|
+
import androidx.compose.ui.layout.onSizeChanged
|
|
43
|
+
import androidx.compose.ui.platform.LocalDensity
|
|
46
44
|
import androidx.compose.ui.semantics.Role
|
|
45
|
+
import androidx.compose.ui.semantics.clearAndSetSemantics
|
|
47
46
|
import androidx.compose.ui.text.style.TextOverflow
|
|
48
47
|
import androidx.compose.ui.unit.Dp
|
|
49
48
|
import androidx.compose.ui.unit.dp
|
|
@@ -62,6 +61,7 @@ import vn.momo.kits.layout.ScreenLayout
|
|
|
62
61
|
import vn.momo.kits.layout.SideBarEdge
|
|
63
62
|
import vn.momo.kits.modifier.noFeedbackClickable
|
|
64
63
|
import vn.momo.kits.modifier.setAutomationId
|
|
64
|
+
import vn.momo.kits.navigation.LocalMaxApi
|
|
65
65
|
|
|
66
66
|
// Figma Fold-Design-System MT1/MT2, mirrored from RN DuoTabBar.tsx.
|
|
67
67
|
private val ITEM_SIZE = DuoMetrics.controlSize
|
|
@@ -76,14 +76,11 @@ private val FLOATING_HEIGHT = DuoMetrics.floatingTabBarHeight
|
|
|
76
76
|
private val FLOATING_PADDING = Spacing.XS
|
|
77
77
|
// the system bar sizes its pill to the tabs instead of stretching it across the display
|
|
78
78
|
private val FLOATING_ITEM_WIDTH = 80.dp
|
|
79
|
-
//
|
|
80
|
-
private
|
|
81
|
-
private
|
|
79
|
+
// the rail's badge stays inside the capsule: its end sits XXS off the cell's end edge
|
|
80
|
+
private val RAIL_BADGE_MAX_WIDTH = ITEM_SIZE - Spacing.XXS * 2
|
|
81
|
+
private val RAIL_BADGE_END_OFFSET = (ITEM_SIZE - ICON_SIZE) / 2 - Spacing.XXS
|
|
82
82
|
// Figma Home-2026 tab bar "Selection": brand pink at 8%
|
|
83
83
|
private const val SELECTION_TINT_ALPHA = 0x14 / 255f
|
|
84
|
-
// RN reanimated spring { damping: 18, stiffness: 220, mass: 0.8 }
|
|
85
|
-
private val BUBBLE_SPRING = spring<Dp>(dampingRatio = 0.68f, stiffness = 275f, visibilityThreshold = Dp(0.5f))
|
|
86
|
-
private val LENS_SPRING = spring<Float>(dampingRatio = 0.68f, stiffness = 275f)
|
|
87
84
|
|
|
88
85
|
// Space from the window bottom the rail stack and its gap take, so the header side bar stays above it.
|
|
89
86
|
internal val LocalDuoRailReserve = compositionLocalOf { 0.dp }
|
|
@@ -112,11 +109,15 @@ internal fun BoxScope.DuoTabBar(
|
|
|
112
109
|
selectedIndex: Int,
|
|
113
110
|
onTabSelected: (Int) -> Unit,
|
|
114
111
|
compression: DuoBarCompression = DuoBarCompression(),
|
|
112
|
+
onTabLongPress: ((Int) -> Unit)? = null,
|
|
115
113
|
) {
|
|
114
|
+
val maxApi = LocalMaxApi.current
|
|
115
|
+
// a tick per tab the lens enters while dragged
|
|
116
|
+
val onTick = remember(maxApi) { { runCatching { maxApi?.triggerEventVibration("light", callback = null) }; Unit } }
|
|
116
117
|
if (layout.sideBar != null) {
|
|
117
|
-
DuoRailTabBar(layout, items, floatingButton, selectedIndex, onTabSelected, compression)
|
|
118
|
+
DuoRailTabBar(layout, items, floatingButton, selectedIndex, onTabSelected, compression, onTabLongPress, onTick)
|
|
118
119
|
} else {
|
|
119
|
-
DuoFloatingTabBar(layout, items, floatingButton, selectedIndex, onTabSelected)
|
|
120
|
+
DuoFloatingTabBar(layout, items, floatingButton, selectedIndex, onTabSelected, onTabLongPress, onTick)
|
|
120
121
|
}
|
|
121
122
|
}
|
|
122
123
|
|
|
@@ -131,6 +132,8 @@ private fun BoxScope.DuoRailTabBar(
|
|
|
131
132
|
selectedIndex: Int,
|
|
132
133
|
onTabSelected: (Int) -> Unit,
|
|
133
134
|
compression: DuoBarCompression,
|
|
135
|
+
onTabLongPress: ((Int) -> Unit)?,
|
|
136
|
+
onTick: () -> Unit,
|
|
134
137
|
) {
|
|
135
138
|
val sideBar = layout.sideBar ?: return
|
|
136
139
|
val isRight = sideBar.edge == SideBarEdge.RIGHT
|
|
@@ -146,18 +149,20 @@ private fun BoxScope.DuoRailTabBar(
|
|
|
146
149
|
) {
|
|
147
150
|
val current = items.getOrNull(selectedIndex)
|
|
148
151
|
if (compression.tabBarCompressed && current != null) {
|
|
152
|
+
val interactionSource = remember { MutableInteractionSource() }
|
|
149
153
|
Box(
|
|
150
154
|
modifier = Modifier
|
|
155
|
+
.pressLift(interactionSource)
|
|
151
156
|
.size(ITEM_SIZE)
|
|
152
157
|
.tabBarMaterial(CircleShape)
|
|
153
|
-
.
|
|
158
|
+
.clickable(interactionSource = interactionSource, indication = null, onClick = compression.expandTabBar)
|
|
154
159
|
.setAutomationId("bottom_tab_bar"),
|
|
155
160
|
contentAlignment = Alignment.Center,
|
|
156
161
|
) {
|
|
157
|
-
DuoTabIcon(current, selected = true)
|
|
162
|
+
DuoTabIcon(current, selected = true, badgeMaxWidth = RAIL_BADGE_MAX_WIDTH, inRail = true)
|
|
158
163
|
}
|
|
159
164
|
} else {
|
|
160
|
-
Rail(items, selectedIndex) {
|
|
165
|
+
Rail(items, selectedIndex, onTabLongPress, onTick) {
|
|
161
166
|
onTabSelected(it)
|
|
162
167
|
compression.onTabSelected()
|
|
163
168
|
}
|
|
@@ -168,42 +173,55 @@ private fun BoxScope.DuoRailTabBar(
|
|
|
168
173
|
}
|
|
169
174
|
}
|
|
170
175
|
|
|
171
|
-
// Column of icon-only tab items with the sliding selection
|
|
176
|
+
// Column of icon-only tab items with the sliding selection lens.
|
|
172
177
|
@Composable
|
|
173
|
-
private fun Rail(
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
178
|
+
private fun Rail(
|
|
179
|
+
items: List<BottomTabItem>,
|
|
180
|
+
selectedIndex: Int,
|
|
181
|
+
onTabLongPress: ((Int) -> Unit)?,
|
|
182
|
+
onTick: () -> Unit,
|
|
183
|
+
onItemClick: (Int) -> Unit,
|
|
184
|
+
) {
|
|
185
|
+
val selection = rememberDuoLiquidSelection(
|
|
186
|
+
count = items.size,
|
|
187
|
+
pitch = ITEM_SIZE + RAIL_GAP,
|
|
188
|
+
firstCenter = RAIL_PADDING + ITEM_SIZE / 2,
|
|
189
|
+
axis = LiquidAxis.Y,
|
|
190
|
+
index = selectedIndex,
|
|
191
|
+
onSelect = onItemClick,
|
|
192
|
+
onTick = onTick,
|
|
179
193
|
)
|
|
180
194
|
Box(
|
|
181
195
|
Modifier
|
|
196
|
+
.then(selection.barTransform)
|
|
182
197
|
.width(ITEM_SIZE)
|
|
183
198
|
.tabBarMaterial(CircleShape)
|
|
184
199
|
.setAutomationId("bottom_tab_bar")
|
|
200
|
+
.then(selection.dragGesture)
|
|
185
201
|
) {
|
|
186
202
|
Box(
|
|
187
203
|
Modifier
|
|
188
204
|
.offset(
|
|
189
205
|
x = (ITEM_SIZE - RAIL_BUBBLE_WIDTH) / 2,
|
|
190
|
-
y = RAIL_PADDING - (RAIL_BUBBLE_HEIGHT - ITEM_SIZE) / 2
|
|
206
|
+
y = RAIL_PADDING - (RAIL_BUBBLE_HEIGHT - ITEM_SIZE) / 2,
|
|
191
207
|
)
|
|
208
|
+
.then(selection.bubbleTransform)
|
|
192
209
|
.size(RAIL_BUBBLE_WIDTH, RAIL_BUBBLE_HEIGHT)
|
|
193
|
-
.
|
|
210
|
+
.selectionTint()
|
|
194
211
|
)
|
|
195
212
|
Column(
|
|
196
213
|
modifier = Modifier.padding(vertical = RAIL_PADDING),
|
|
197
214
|
verticalArrangement = Arrangement.spacedBy(RAIL_GAP),
|
|
198
215
|
) {
|
|
199
216
|
items.forEachIndexed { index, item ->
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
217
|
+
LiquidTab(
|
|
218
|
+
selection = selection,
|
|
219
|
+
index = index,
|
|
220
|
+
onSelect = { onItemClick(index) },
|
|
221
|
+
onLongPress = onTabLongPress?.let { { it(index) } },
|
|
222
|
+
modifier = Modifier.size(ITEM_SIZE),
|
|
223
|
+
) { active ->
|
|
224
|
+
DuoTabIcon(item, active, badgeMaxWidth = RAIL_BADGE_MAX_WIDTH, inRail = true)
|
|
207
225
|
}
|
|
208
226
|
}
|
|
209
227
|
}
|
|
@@ -218,8 +236,21 @@ private fun BoxScope.DuoFloatingTabBar(
|
|
|
218
236
|
floatingButton: BottomTabFloatingButton?,
|
|
219
237
|
selectedIndex: Int,
|
|
220
238
|
onTabSelected: (Int) -> Unit,
|
|
239
|
+
onTabLongPress: ((Int) -> Unit)?,
|
|
240
|
+
onTick: () -> Unit,
|
|
221
241
|
) {
|
|
222
|
-
val
|
|
242
|
+
val density = LocalDensity.current
|
|
243
|
+
var rowWidth by remember { mutableStateOf(0.dp) }
|
|
244
|
+
val cellWidth = rowWidth / items.size.coerceAtLeast(1)
|
|
245
|
+
val selection = rememberDuoLiquidSelection(
|
|
246
|
+
count = items.size,
|
|
247
|
+
pitch = cellWidth,
|
|
248
|
+
firstCenter = cellWidth / 2,
|
|
249
|
+
axis = LiquidAxis.X,
|
|
250
|
+
index = selectedIndex,
|
|
251
|
+
onSelect = onTabSelected,
|
|
252
|
+
onTick = onTick,
|
|
253
|
+
)
|
|
223
254
|
|
|
224
255
|
Row(
|
|
225
256
|
modifier = Modifier
|
|
@@ -237,28 +268,36 @@ private fun BoxScope.DuoFloatingTabBar(
|
|
|
237
268
|
.weight(1f, fill = false)
|
|
238
269
|
.width(FLOATING_ITEM_WIDTH * items.size + FLOATING_PADDING * 2)
|
|
239
270
|
.height(FLOATING_HEIGHT)
|
|
271
|
+
.then(selection.barTransform)
|
|
240
272
|
.tabBarMaterial(RoundedCornerShape(FLOATING_HEIGHT / 2))
|
|
241
273
|
.padding(FLOATING_PADDING)
|
|
242
274
|
.setAutomationId("bottom_tab_bar")
|
|
243
275
|
) {
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
)
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
276
|
+
Box(
|
|
277
|
+
Modifier
|
|
278
|
+
.fillMaxSize()
|
|
279
|
+
.onSizeChanged { rowWidth = with(density) { it.width.toDp() } }
|
|
280
|
+
.then(selection.dragGesture)
|
|
281
|
+
) {
|
|
282
|
+
if (rowWidth > 0.dp) {
|
|
283
|
+
Box(
|
|
284
|
+
Modifier
|
|
285
|
+
.then(selection.bubbleTransform)
|
|
286
|
+
.width(cellWidth)
|
|
287
|
+
.fillMaxHeight()
|
|
288
|
+
.selectionTint()
|
|
289
|
+
)
|
|
290
|
+
}
|
|
258
291
|
Row(Modifier.fillMaxSize()) {
|
|
259
292
|
items.forEachIndexed { index, item ->
|
|
260
|
-
|
|
261
|
-
|
|
293
|
+
LiquidTab(
|
|
294
|
+
selection = selection,
|
|
295
|
+
index = index,
|
|
296
|
+
onSelect = { onTabSelected(index) },
|
|
297
|
+
onLongPress = onTabLongPress?.let { { it(index) } },
|
|
298
|
+
modifier = Modifier.weight(1f).fillMaxHeight(),
|
|
299
|
+
) { active ->
|
|
300
|
+
FloatingCellContent(item, active, badgeMaxWidth = cellWidth / 2 - Spacing.XXS)
|
|
262
301
|
}
|
|
263
302
|
}
|
|
264
303
|
}
|
|
@@ -270,24 +309,56 @@ private fun BoxScope.DuoFloatingTabBar(
|
|
|
270
309
|
}
|
|
271
310
|
}
|
|
272
311
|
|
|
312
|
+
// A tab drawn twice, inactive with the active layer over it fading in as the lens passes; the tab
|
|
313
|
+
// the lens lands on pops. A tap is the clickable's, a drag over the bar is the bar's.
|
|
273
314
|
@Composable
|
|
274
|
-
private fun
|
|
275
|
-
|
|
276
|
-
selected: Boolean,
|
|
315
|
+
private fun LiquidTab(
|
|
316
|
+
selection: DuoLiquidSelection,
|
|
277
317
|
index: Int,
|
|
278
|
-
|
|
279
|
-
|
|
318
|
+
onSelect: () -> Unit,
|
|
319
|
+
onLongPress: (() -> Unit)?,
|
|
320
|
+
modifier: Modifier = Modifier,
|
|
321
|
+
content: @Composable (active: Boolean) -> Unit,
|
|
280
322
|
) {
|
|
323
|
+
val interactionSource = remember { MutableInteractionSource() }
|
|
324
|
+
val onLongClick = selection.rememberPressTracking(index, interactionSource, onSelect, onLongPress)
|
|
325
|
+
val pop = selection.rememberIconPop(index)
|
|
326
|
+
Box(
|
|
327
|
+
modifier = modifier
|
|
328
|
+
.combinedClickable(
|
|
329
|
+
interactionSource = interactionSource,
|
|
330
|
+
indication = null,
|
|
331
|
+
role = Role.Tab,
|
|
332
|
+
onLongClick = onLongClick,
|
|
333
|
+
onClick = onSelect,
|
|
334
|
+
)
|
|
335
|
+
.graphicsLayer {
|
|
336
|
+
scaleX = pop.value
|
|
337
|
+
scaleY = pop.value
|
|
338
|
+
},
|
|
339
|
+
contentAlignment = Alignment.Center,
|
|
340
|
+
) {
|
|
341
|
+
content(false)
|
|
342
|
+
Box(
|
|
343
|
+
Modifier
|
|
344
|
+
.matchParentSize()
|
|
345
|
+
.graphicsLayer { alpha = selection.activeAlpha(index) }
|
|
346
|
+
.clearAndSetSemantics {},
|
|
347
|
+
contentAlignment = Alignment.Center,
|
|
348
|
+
) {
|
|
349
|
+
content(true)
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
@Composable
|
|
355
|
+
private fun FloatingCellContent(item: BottomTabItem, active: Boolean, badgeMaxWidth: Dp) {
|
|
281
356
|
val colors = AppTheme.current.colors
|
|
282
357
|
Column(
|
|
283
|
-
modifier = Modifier
|
|
284
|
-
.weight(1f)
|
|
285
|
-
.fillMaxHeight()
|
|
286
|
-
.tabClickable(index, pressedIndex, onClick),
|
|
287
358
|
horizontalAlignment = Alignment.CenterHorizontally,
|
|
288
|
-
verticalArrangement = Arrangement.spacedBy(Spacing.XXS
|
|
359
|
+
verticalArrangement = Arrangement.spacedBy(Spacing.XXS),
|
|
289
360
|
) {
|
|
290
|
-
DuoTabIcon(item,
|
|
361
|
+
DuoTabIcon(item, active, badgeMaxWidth)
|
|
291
362
|
// like the system tab bar the label keeps its design size; Text grows it up to 5pt on the wide Duo displays
|
|
292
363
|
val context = LocalContext.current
|
|
293
364
|
val fixedScale = remember(context) {
|
|
@@ -297,7 +368,7 @@ private fun RowScope.FloatingCell(
|
|
|
297
368
|
Text(
|
|
298
369
|
text = item.label,
|
|
299
370
|
style = Typography.labelXsMedium,
|
|
300
|
-
color = if (
|
|
371
|
+
color = if (active) colors.primary else colors.text.hint,
|
|
301
372
|
maxLines = 1,
|
|
302
373
|
overflow = TextOverflow.Ellipsis,
|
|
303
374
|
)
|
|
@@ -305,31 +376,15 @@ private fun RowScope.FloatingCell(
|
|
|
305
376
|
}
|
|
306
377
|
}
|
|
307
378
|
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
private fun Modifier.tabClickable(index: Int, pressedIndex: MutableState<Int?>, onClick: () -> Unit): Modifier {
|
|
311
|
-
val interactionSource = remember { MutableInteractionSource() }
|
|
312
|
-
val pressed by interactionSource.collectIsPressedAsState()
|
|
313
|
-
LaunchedEffect(pressed) {
|
|
314
|
-
if (pressed) pressedIndex.value = index
|
|
315
|
-
else if (pressedIndex.value == index) pressedIndex.value = null
|
|
316
|
-
}
|
|
317
|
-
return clickable(interactionSource = interactionSource, indication = null, role = Role.Tab, onClick = onClick)
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
// Selection bubble; while pressed it grows into a lens (80x54 to about 96x70).
|
|
321
|
-
@Composable
|
|
322
|
-
private fun Modifier.selectionLens(pressed: Boolean): Modifier {
|
|
323
|
-
val lens by animateFloatAsState(if (pressed) 1f else 0f, LENS_SPRING, label = "DuoSelectionLens")
|
|
324
|
-
val tint = AppTheme.current.colors.primary.copy(alpha = SELECTION_TINT_ALPHA)
|
|
325
|
-
return graphicsLayer {
|
|
326
|
-
scaleX = 1 + lens * (LENS_SCALE_X - 1)
|
|
327
|
-
scaleY = 1 + lens * (LENS_SCALE_Y - 1)
|
|
328
|
-
}.background(tint, CircleShape)
|
|
379
|
+
private fun Modifier.selectionTint(): Modifier = composed {
|
|
380
|
+
background(AppTheme.current.colors.primary.copy(alpha = SELECTION_TINT_ALPHA), CircleShape)
|
|
329
381
|
}
|
|
330
382
|
|
|
383
|
+
// Floating bar: the badge starts at the icon's center and grows toward the cell's end edge.
|
|
384
|
+
// Rail: the material clips to the capsule, so the badge ends just inside the rail and grows back
|
|
385
|
+
// over the icon (RN TabBarIcon `end: -3`). Either way it never resizes the icon box.
|
|
331
386
|
@Composable
|
|
332
|
-
private fun DuoTabIcon(item: BottomTabItem, selected: Boolean) {
|
|
387
|
+
private fun DuoTabIcon(item: BottomTabItem, selected: Boolean, badgeMaxWidth: Dp, inRail: Boolean = false) {
|
|
333
388
|
val colors = AppTheme.current.colors
|
|
334
389
|
val badgeLabel = item.badgeLabel?.takeUnless { it.toIntOrNull() == 0 }
|
|
335
390
|
Box {
|
|
@@ -346,7 +401,11 @@ private fun DuoTabIcon(item: BottomTabItem, selected: Boolean) {
|
|
|
346
401
|
)
|
|
347
402
|
else -> Badge(
|
|
348
403
|
badgeLabel,
|
|
349
|
-
modifier = Modifier
|
|
404
|
+
modifier = Modifier
|
|
405
|
+
.matchParentSize()
|
|
406
|
+
.wrapContentSize(if (inRail) Alignment.TopEnd else Alignment.TopStart, unbounded = true)
|
|
407
|
+
.offset(x = if (inRail) RAIL_BADGE_END_OFFSET else ICON_SIZE / 2, y = -Spacing.XS)
|
|
408
|
+
.widthIn(max = badgeMaxWidth.coerceAtLeast(0.dp)),
|
|
350
409
|
)
|
|
351
410
|
}
|
|
352
411
|
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
package vn.momo.kits.platform
|
|
2
|
+
|
|
3
|
+
import androidx.compose.runtime.Composable
|
|
4
|
+
import androidx.compose.runtime.DisposableEffect
|
|
5
|
+
import androidx.compose.runtime.mutableStateOf
|
|
6
|
+
import androidx.compose.runtime.remember
|
|
7
|
+
import kotlinx.cinterop.ExperimentalForeignApi
|
|
8
|
+
import kotlinx.cinterop.ObjCAction
|
|
9
|
+
import platform.Foundation.NSNotification
|
|
10
|
+
import platform.Foundation.NSNotificationCenter
|
|
11
|
+
import platform.Foundation.NSSelectorFromString
|
|
12
|
+
import platform.UIKit.UIAccessibilityIsReduceMotionEnabled
|
|
13
|
+
import platform.UIKit.UIAccessibilityReduceMotionStatusDidChangeNotification
|
|
14
|
+
import platform.darwin.NSObject
|
|
15
|
+
|
|
16
|
+
@OptIn(ExperimentalForeignApi::class)
|
|
17
|
+
@Composable
|
|
18
|
+
actual fun rememberReduceMotion(): Boolean {
|
|
19
|
+
val reduceMotion = remember { mutableStateOf(UIAccessibilityIsReduceMotionEnabled()) }
|
|
20
|
+
|
|
21
|
+
val listener = remember {
|
|
22
|
+
object : NSObject() {
|
|
23
|
+
@Suppress("unused")
|
|
24
|
+
@ObjCAction
|
|
25
|
+
fun reduceMotionChanged(arg: NSNotification) {
|
|
26
|
+
reduceMotion.value = UIAccessibilityIsReduceMotionEnabled()
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
DisposableEffect(Unit) {
|
|
32
|
+
NSNotificationCenter.defaultCenter.addObserver(
|
|
33
|
+
observer = listener,
|
|
34
|
+
selector = NSSelectorFromString(listener::reduceMotionChanged.name + ":"),
|
|
35
|
+
name = UIAccessibilityReduceMotionStatusDidChangeNotification,
|
|
36
|
+
`object` = null,
|
|
37
|
+
)
|
|
38
|
+
onDispose {
|
|
39
|
+
NSNotificationCenter.defaultCenter.removeObserver(
|
|
40
|
+
observer = listener,
|
|
41
|
+
name = UIAccessibilityReduceMotionStatusDidChangeNotification,
|
|
42
|
+
`object` = null,
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return reduceMotion.value
|
|
48
|
+
}
|
package/gradle.properties
CHANGED
|
@@ -16,6 +16,7 @@ public struct BottomTab: View {
|
|
|
16
16
|
private let compressionBehavior: DuoBarCompressionBehavior
|
|
17
17
|
private let onTabPress: ((_ index: Int, _ switchTab: @escaping () -> Void) -> Void)?
|
|
18
18
|
private let backToInitialTab: Bool
|
|
19
|
+
private let onTabLongPress: ((_ index: Int) -> Void)?
|
|
19
20
|
|
|
20
21
|
@EnvironmentObject private var navigator: Navigator
|
|
21
22
|
/// The screen hosting the tabs (Compose's `LocalNavigation` in BottomTab).
|
|
@@ -34,19 +35,22 @@ public struct BottomTab: View {
|
|
|
34
35
|
/// starts compressed; tapping the compressed one swaps them.
|
|
35
36
|
/// `onTabPress` gets every tab press with the switch to perform, e.g. after a login gate.
|
|
36
37
|
/// `backToInitialTab`: back off another tab returns to the initial one before leaving the screen.
|
|
38
|
+
/// `onTabLongPress`: RN `tabLongPress`; on iPhone Duo the held tab is still selected on release.
|
|
37
39
|
public init(
|
|
38
40
|
items: [BottomTabItem],
|
|
39
41
|
floatingButton: BottomTabFloatingButton? = nil,
|
|
40
42
|
compressionBehavior: DuoBarCompressionBehavior = .toolbar,
|
|
41
43
|
initialRouteName: String? = nil,
|
|
42
44
|
onTabPress: ((_ index: Int, _ switchTab: @escaping () -> Void) -> Void)? = nil,
|
|
43
|
-
backToInitialTab: Bool = true
|
|
45
|
+
backToInitialTab: Bool = true,
|
|
46
|
+
onTabLongPress: ((_ index: Int) -> Void)? = nil
|
|
44
47
|
) {
|
|
45
48
|
self.items = items
|
|
46
49
|
self.floatingButton = floatingButton
|
|
47
50
|
self.compressionBehavior = compressionBehavior
|
|
48
51
|
self.onTabPress = onTabPress
|
|
49
52
|
self.backToInitialTab = backToInitialTab
|
|
53
|
+
self.onTabLongPress = onTabLongPress
|
|
50
54
|
let start = items.firstIndex { $0.name == initialRouteName } ?? 0
|
|
51
55
|
_startIndex = State(initialValue: start)
|
|
52
56
|
_selectedIndex = State(initialValue: start)
|
|
@@ -101,7 +105,15 @@ public struct BottomTab: View {
|
|
|
101
105
|
floatingButton: floatingButton,
|
|
102
106
|
selectedIndex: selectedIndex,
|
|
103
107
|
onTabSelected: selectTab,
|
|
104
|
-
compression: compression
|
|
108
|
+
compression: compression,
|
|
109
|
+
onTabLongPress: onTabLongPress,
|
|
110
|
+
onTick: { [maxApi = navigator.maxApi] in
|
|
111
|
+
if let maxApi {
|
|
112
|
+
maxApi.triggerEventVibration("light")
|
|
113
|
+
} else {
|
|
114
|
+
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
|
115
|
+
}
|
|
116
|
+
}
|
|
105
117
|
)
|
|
106
118
|
} else {
|
|
107
119
|
// Tab bar + home-indicator filler.
|
|
@@ -110,7 +122,8 @@ public struct BottomTab: View {
|
|
|
110
122
|
items: items,
|
|
111
123
|
floatingButton: floatingButton,
|
|
112
124
|
selectedIndex: selectedIndex,
|
|
113
|
-
onTabSelected: selectTab
|
|
125
|
+
onTabSelected: selectTab,
|
|
126
|
+
onTabLongPress: onTabLongPress
|
|
114
127
|
)
|
|
115
128
|
theme.colors.background.surface
|
|
116
129
|
.frame(height: bottomInset + Spacing.S)
|
|
@@ -67,6 +67,7 @@ struct BottomTabBar: View {
|
|
|
67
67
|
let floatingButton: BottomTabFloatingButton?
|
|
68
68
|
let selectedIndex: Int
|
|
69
69
|
let onTabSelected: (Int) -> Void
|
|
70
|
+
var onTabLongPress: ((Int) -> Void)?
|
|
70
71
|
|
|
71
72
|
@Environment(\.screenLayout) private var layout
|
|
72
73
|
@Environment(\.appTheme) private var theme
|
|
@@ -133,7 +134,10 @@ struct BottomTabBar: View {
|
|
|
133
134
|
TabBarItem(
|
|
134
135
|
item: items[index],
|
|
135
136
|
selected: index == selectedIndex,
|
|
136
|
-
|
|
137
|
+
// from the icon's center to the cell's end edge, inside both XXS paddings
|
|
138
|
+
badgeMaxWidth: tabWidth / 2 - Spacing.XXS * 2,
|
|
139
|
+
onClick: { onTabSelected(index) },
|
|
140
|
+
onLongClick: onTabLongPress.map { callback in { callback(index) } }
|
|
137
141
|
)
|
|
138
142
|
.frame(maxWidth: .infinity)
|
|
139
143
|
}
|
|
@@ -145,7 +149,9 @@ struct BottomTabBar: View {
|
|
|
145
149
|
private struct TabBarItem: View {
|
|
146
150
|
let item: BottomTabItem
|
|
147
151
|
let selected: Bool
|
|
152
|
+
let badgeMaxWidth: CGFloat
|
|
148
153
|
let onClick: () -> Void
|
|
154
|
+
let onLongClick: (() -> Void)?
|
|
149
155
|
|
|
150
156
|
@Environment(\.appTheme) private var theme
|
|
151
157
|
|
|
@@ -159,40 +165,52 @@ private struct TabBarItem: View {
|
|
|
159
165
|
}
|
|
160
166
|
|
|
161
167
|
var body: some View {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
maxLines: 1
|
|
172
|
-
)
|
|
173
|
-
.truncationMode(.tail)
|
|
174
|
-
}
|
|
175
|
-
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
176
|
-
.padding(.horizontal, Spacing.S)
|
|
177
|
-
.padding(.vertical, Spacing.S)
|
|
178
|
-
|
|
179
|
-
if let badgeLabel {
|
|
180
|
-
if badgeLabel.isEmpty {
|
|
181
|
-
BadgeDot(size: .small)
|
|
182
|
-
.offset(x: -Spacing.S, y: Spacing.XS)
|
|
183
|
-
} else {
|
|
184
|
-
Badge(label: badgeLabel)
|
|
185
|
-
.offset(x: -Spacing.XS, y: 0)
|
|
168
|
+
VStack(spacing: 0) {
|
|
169
|
+
Icon(source: item.icon, size: 28, color: tintColor)
|
|
170
|
+
.overlay(alignment: .topLeading) {
|
|
171
|
+
// starts at the icon's center and grows toward the cell's end edge, ellipsized past it
|
|
172
|
+
if let badgeLabel, !badgeLabel.isEmpty {
|
|
173
|
+
Badge(label: badgeLabel)
|
|
174
|
+
.frame(width: max(badgeMaxWidth, 0), alignment: .leading)
|
|
175
|
+
.offset(x: 14, y: -Spacing.S)
|
|
176
|
+
}
|
|
186
177
|
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
178
|
+
.overlay(alignment: .topTrailing) {
|
|
179
|
+
if badgeLabel?.isEmpty == true || (badgeLabel == nil && item.showDot) {
|
|
180
|
+
BadgeDot(size: .small).offset(x: 4, y: -4)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
.padding(Spacing.XS)
|
|
184
|
+
MomoText(
|
|
185
|
+
item.label,
|
|
186
|
+
typography: selected ? .labelXsMedium : .descriptionXsRegular,
|
|
187
|
+
color: tintColor,
|
|
188
|
+
textAlign: .center,
|
|
189
|
+
maxLines: 1
|
|
190
|
+
)
|
|
191
|
+
.truncationMode(.tail)
|
|
191
192
|
}
|
|
192
193
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
194
|
+
.padding(.horizontal, Spacing.S)
|
|
195
|
+
.padding(.vertical, Spacing.S)
|
|
196
|
+
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
193
197
|
.padding(.horizontal, Spacing.XXS)
|
|
194
198
|
.contentShape(Rectangle())
|
|
195
199
|
.onTapGesture { onClick() }
|
|
200
|
+
.modifier(TabLongPress(action: onLongClick))
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/// Only attached with a callback, so a held tab without one still selects on release.
|
|
205
|
+
private struct TabLongPress: ViewModifier {
|
|
206
|
+
let action: (() -> Void)?
|
|
207
|
+
|
|
208
|
+
func body(content: Content) -> some View {
|
|
209
|
+
if let action {
|
|
210
|
+
content.onLongPressGesture(minimumDuration: 0.5, perform: action)
|
|
211
|
+
} else {
|
|
212
|
+
content
|
|
213
|
+
}
|
|
196
214
|
}
|
|
197
215
|
}
|
|
198
216
|
|