@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.
@@ -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
- // while pressed the selection bubble grows into a lens past the pill's edges
80
- private const val LENS_SCALE_X = 1.2f
81
- private const val LENS_SCALE_Y = 1.3f
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
- .noFeedbackClickable(onClick = compression.expandTabBar)
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 bubble.
176
+ // Column of icon-only tab items with the sliding selection lens.
172
177
  @Composable
173
- private fun Rail(items: List<BottomTabItem>, selectedIndex: Int, onItemClick: (Int) -> Unit) {
174
- val pressedIndex = remember { mutableStateOf<Int?>(null) }
175
- val bubbleOffset by animateDpAsState(
176
- targetValue = (ITEM_SIZE + RAIL_GAP) * (pressedIndex.value ?: selectedIndex).coerceAtLeast(0),
177
- animationSpec = BUBBLE_SPRING,
178
- label = "DuoRailBubble",
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 + bubbleOffset,
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
- .selectionLens(pressedIndex.value != null)
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
- Box(
201
- modifier = Modifier
202
- .size(ITEM_SIZE)
203
- .tabClickable(index, pressedIndex) { onItemClick(index) },
204
- contentAlignment = Alignment.Center,
205
- ) {
206
- DuoTabIcon(item, selected = index == selectedIndex)
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 pressedIndex = remember { mutableStateOf<Int?>(null) }
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
- BoxWithConstraints(Modifier.fillMaxSize()) {
245
- val cellWidth = maxWidth / items.size.coerceAtLeast(1)
246
- val bubbleOffset by animateDpAsState(
247
- targetValue = cellWidth * (pressedIndex.value ?: selectedIndex).coerceAtLeast(0),
248
- animationSpec = BUBBLE_SPRING,
249
- label = "DuoFloatingBubble",
250
- )
251
- Box(
252
- Modifier
253
- .offset(x = bubbleOffset)
254
- .width(cellWidth)
255
- .fillMaxHeight()
256
- .selectionLens(pressedIndex.value != null)
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
- FloatingCell(item, selected = index == selectedIndex, index, pressedIndex) {
261
- onTabSelected(index)
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 RowScope.FloatingCell(
275
- item: BottomTabItem,
276
- selected: Boolean,
315
+ private fun LiquidTab(
316
+ selection: DuoLiquidSelection,
277
317
  index: Int,
278
- pressedIndex: MutableState<Int?>,
279
- onClick: () -> Unit,
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, Alignment.CenterVertically),
359
+ verticalArrangement = Arrangement.spacedBy(Spacing.XXS),
289
360
  ) {
290
- DuoTabIcon(item, selected)
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 (selected) colors.primary else colors.text.hint,
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
- // Tracks the finger so the selection lens jumps to the pressed tab before it is selected.
309
- @Composable
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.align(Alignment.TopStart).offset(x = ICON_SIZE / 2, y = -Spacing.XS),
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,7 @@
1
+ package vn.momo.kits.platform
2
+
3
+ import androidx.compose.runtime.Composable
4
+
5
+ // OS "Reduce Motion" (iOS) / animations off (Android), live.
6
+ @Composable
7
+ expect fun rememberReduceMotion(): Boolean
@@ -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
@@ -18,7 +18,7 @@ kotlin.apple.xcodeCompatibility.nowarn=true
18
18
  name="ComposeKits"
19
19
  group=vn.momo.kits
20
20
  artifact.id=kits
21
- version=0.168.1-beta.24
21
+ version=0.168.1-beta.8
22
22
 
23
23
  repo=GitLab
24
24
  url=https://gitlab.mservice.com.vn/api/v4/projects/5400/packages/maven
@@ -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
- onClick: { onTabSelected(index) }
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
- ZStack(alignment: .topTrailing) {
163
- VStack(spacing: 0) {
164
- Icon(source: item.icon, size: 28, color: tintColor)
165
- .padding(Spacing.XS)
166
- MomoText(
167
- item.label,
168
- typography: selected ? .labelXsMedium : .descriptionXsRegular,
169
- color: tintColor,
170
- textAlign: .center,
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
- } else if item.showDot {
188
- BadgeDot(size: .small)
189
- .offset(x: -Spacing.S, y: Spacing.XS)
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