@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.
@@ -20,12 +20,11 @@ private let floatingHeight = DuoMetrics.floatingTabBarHeight
20
20
  private let floatingPadding = Spacing.XS
21
21
  // the system bar sizes its pill to the tabs instead of stretching it across the display
22
22
  private let floatingItemWidth: CGFloat = 80
23
- // while pressed the selection bubble grows into a lens past the pill's edges
24
- private let lensScaleX: CGFloat = 1.2
25
- private let lensScaleY: CGFloat = 1.3
23
+ // the rail's badge stays inside the capsule: its end sits XXS off the cell's end edge
24
+ private let railBadgeMaxWidth = itemSize - Spacing.XXS * 2
25
+ private let railBadgeEndOffset = (itemSize - iconSize) / 2 - Spacing.XXS
26
26
  // Figma Home-2026 tab bar "Selection": brand pink at 8%
27
27
  private let selectionTintOpacity: Double = 0x14 / 255
28
- private let tabSpring = Animation.interpolatingSpring(mass: 0.8, stiffness: 220, damping: 18)
29
28
 
30
29
  /// Room tab screens leave for the Duo tab bar, provided by BottomTab to its tab StackScreens.
31
30
  struct DuoTabBarInsets: Equatable {
@@ -91,16 +90,20 @@ struct DuoTabBar: View {
91
90
  let selectedIndex: Int
92
91
  let onTabSelected: (Int) -> Void
93
92
  var compression = DuoBarCompression()
93
+ var onTabLongPress: ((Int) -> Void)?
94
+ /// A tick per tab the lens enters while dragged.
95
+ var onTick: () -> Void = {}
94
96
 
95
97
  @Environment(\.screenLayout) private var layout
96
98
 
97
99
  var body: some View {
98
100
  if let sideBar = layout.sideBar {
99
101
  DuoRailTabBar(sideBar: sideBar, items: items, floatingButton: floatingButton,
100
- selectedIndex: selectedIndex, onTabSelected: onTabSelected, compression: compression)
102
+ selectedIndex: selectedIndex, onTabSelected: onTabSelected, compression: compression,
103
+ onTabLongPress: onTabLongPress, onTick: onTick)
101
104
  } else {
102
- DuoFloatingTabBar(items: items, floatingButton: floatingButton,
103
- selectedIndex: selectedIndex, onTabSelected: onTabSelected)
105
+ DuoFloatingTabBar(items: items, floatingButton: floatingButton, selectedIndex: selectedIndex,
106
+ onTabSelected: onTabSelected, onTabLongPress: onTabLongPress, onTick: onTick)
104
107
  }
105
108
  }
106
109
  }
@@ -115,8 +118,11 @@ private struct DuoRailTabBar: View {
115
118
  let selectedIndex: Int
116
119
  let onTabSelected: (Int) -> Void
117
120
  let compression: DuoBarCompression
121
+ let onTabLongPress: ((Int) -> Void)?
122
+ let onTick: () -> Void
118
123
 
119
- @State private var pressedIndex: Int?
124
+ @Environment(\.accessibilityReduceMotion) private var reduceMotion
125
+ @StateObject private var selection = DuoLiquidSelection()
120
126
 
121
127
  private var isRight: Bool { sideBar.edge == .right }
122
128
 
@@ -124,11 +130,11 @@ private struct DuoRailTabBar: View {
124
130
  VStack(spacing: Spacing.M) {
125
131
  if compression.tabBarCompressed, items.indices.contains(selectedIndex) {
126
132
  SwiftUI.Button(action: compression.expandTabBar) {
127
- DuoTabIcon(item: items[selectedIndex], selected: true)
133
+ DuoTabIcon(item: items[selectedIndex], selected: true, badgeMaxWidth: railBadgeMaxWidth, inRail: true)
128
134
  .frame(width: itemSize, height: itemSize)
129
135
  .contentShape(Circle())
130
136
  }
131
- .buttonStyle(.plain)
137
+ .buttonStyle(DuoPressLiftStyle())
132
138
  .modifier(DuoBarMaterial(shape: Circle()))
133
139
  .accessibilityLabel(items[selectedIndex].label)
134
140
  .accessibilityIdentifier("bottom_tab_bar")
@@ -147,35 +153,51 @@ private struct DuoRailTabBar: View {
147
153
  .ignoresSafeArea()
148
154
  }
149
155
 
150
- /// Column of icon-only tab items with the sliding selection bubble.
156
+ private func select(_ index: Int) {
157
+ onTabSelected(index)
158
+ compression.onTabSelected()
159
+ }
160
+
161
+ /// Column of icon-only tab items with the sliding selection lens.
151
162
  private var rail: some View {
152
- ZStack(alignment: .top) {
153
- DuoSelectionMaterial(lens: pressedIndex != nil)
163
+ let selection = configured()
164
+ let lens = selection.lensScale
165
+ return ZStack(alignment: .top) {
166
+ DuoSelectionMaterial()
154
167
  .frame(width: railBubbleWidth, height: railBubbleHeight)
155
- .offset(y: railPadding - (railBubbleHeight - itemSize) / 2
156
- + CGFloat(pressedIndex ?? selectedIndex) * (itemSize + railGap))
157
- .animation(tabSpring, value: selectedIndex)
158
- .animation(tabSpring, value: pressedIndex)
168
+ .scaleEffect(x: lens.x * lens.across, y: lens.y * lens.along)
169
+ .offset(y: railPadding - (railBubbleHeight - itemSize) / 2 + selection.offset)
159
170
 
160
171
  VStack(spacing: railGap) {
161
172
  ForEach(items.indices, id: \.self) { index in
162
- SwiftUI.Button {
163
- onTabSelected(index)
164
- compression.onTabSelected()
165
- } label: {
166
- DuoTabIcon(item: items[index], selected: index == selectedIndex)
173
+ DuoLiquidTab(selection: selection, index: index, label: items[index].label,
174
+ selected: index == selectedIndex, onSelect: { select(index) }) { active in
175
+ DuoTabIcon(item: items[index], selected: active, badgeMaxWidth: railBadgeMaxWidth, inRail: true)
167
176
  .frame(width: itemSize, height: itemSize)
168
- .contentShape(Rectangle())
169
177
  }
170
- .buttonStyle(DuoLensPressStyle(index: index, pressedIndex: $pressedIndex))
171
- .accessibilityLabel(items[index].label)
172
178
  }
173
179
  }
174
180
  .padding(.vertical, railPadding)
175
181
  }
176
182
  .frame(width: itemSize)
183
+ .modifier(DuoLiquidInput(selection: selection, space: "DuoRail"))
177
184
  .modifier(DuoBarMaterial(shape: Capsule()))
185
+ .scaleEffect(selection.barScale)
178
186
  .accessibilityIdentifier("bottom_tab_bar")
187
+ .onAppear { selection.sync(index: selectedIndex) }
188
+ .onChange(of: selectedIndex) { selection.sync(index: $0) }
189
+ }
190
+
191
+ private func configured() -> DuoLiquidSelection {
192
+ selection.axis = .y
193
+ selection.count = items.count
194
+ selection.pitch = itemSize + railGap
195
+ selection.firstCenter = railPadding + itemSize / 2
196
+ selection.reduceMotion = reduceMotion
197
+ selection.onSelect = select
198
+ selection.onLongPress = onTabLongPress
199
+ selection.onTick = onTick
200
+ return selection
179
201
  }
180
202
  }
181
203
 
@@ -185,10 +207,13 @@ private struct DuoFloatingTabBar: View {
185
207
  let floatingButton: BottomTabFloatingButton?
186
208
  let selectedIndex: Int
187
209
  let onTabSelected: (Int) -> Void
210
+ let onTabLongPress: ((Int) -> Void)?
211
+ let onTick: () -> Void
188
212
 
189
213
  @Environment(\.screenLayout) private var layout
190
214
  @Environment(\.appTheme) private var theme
191
- @State private var pressedIndex: Int?
215
+ @Environment(\.accessibilityReduceMotion) private var reduceMotion
216
+ @StateObject private var selection = DuoLiquidSelection()
192
217
 
193
218
  private var barWidth: CGFloat {
194
219
  let margins = layout.insets.leading + layout.insets.trailing + 2 * DuoMetrics.floatingTabBarMargin
@@ -213,82 +238,139 @@ private struct DuoFloatingTabBar: View {
213
238
  }
214
239
 
215
240
  private var bar: some View {
216
- ZStack(alignment: .leading) {
217
- DuoSelectionMaterial(lens: pressedIndex != nil)
241
+ let selection = configured()
242
+ let lens = selection.lensScale
243
+ return ZStack(alignment: .leading) {
244
+ DuoSelectionMaterial()
218
245
  .frame(width: cellWidth)
219
- .offset(x: CGFloat(pressedIndex ?? selectedIndex) * cellWidth)
220
- .animation(tabSpring, value: selectedIndex)
221
- .animation(tabSpring, value: pressedIndex)
246
+ .scaleEffect(x: lens.x * lens.along, y: lens.y * lens.across)
247
+ .offset(x: selection.offset)
222
248
 
223
249
  HStack(spacing: 0) {
224
250
  ForEach(items.indices, id: \.self) { index in
225
- tabCell(index)
251
+ DuoLiquidTab(selection: selection, index: index, label: items[index].label,
252
+ selected: index == selectedIndex, onSelect: { onTabSelected(index) }) { active in
253
+ tabCell(index, active: active)
254
+ }
226
255
  }
227
256
  }
228
257
  }
258
+ .modifier(DuoLiquidInput(selection: selection, space: "DuoFloatingBar"))
229
259
  .padding(floatingPadding)
230
260
  .frame(width: barWidth, height: floatingHeight)
231
261
  .modifier(DuoBarMaterial(shape: Capsule()))
262
+ .scaleEffect(selection.barScale)
232
263
  .accessibilityIdentifier("bottom_tab_bar")
264
+ .onAppear { selection.sync(index: selectedIndex) }
265
+ .onChange(of: selectedIndex) { selection.sync(index: $0) }
266
+ .onChange(of: cellWidth) { _ in selection.sync(index: selectedIndex) }
233
267
  }
234
268
 
235
- private func tabCell(_ index: Int) -> some View {
236
- let selected = index == selectedIndex
237
- return SwiftUI.Button { onTabSelected(index) } label: {
238
- VStack(spacing: Spacing.XXS) {
239
- DuoTabIcon(item: items[index], selected: selected)
240
- MomoText(
241
- items[index].label,
242
- typography: .labelXsMedium,
243
- color: selected ? theme.colors.primary : theme.colors.text.hint,
244
- textAlign: .center,
245
- maxLines: 1
246
- )
247
- // like the system tab bar the label keeps its design size; a constant snapshot, nothing to observe
248
- .environment(\.momoFontScale, FontScale(userScaleRate: 1, useOSScaleRate: false))
249
- }
250
- .frame(width: cellWidth)
251
- .frame(maxHeight: .infinity)
269
+ private func configured() -> DuoLiquidSelection {
270
+ selection.axis = .x
271
+ selection.count = items.count
272
+ selection.pitch = cellWidth
273
+ selection.firstCenter = cellWidth / 2
274
+ selection.reduceMotion = reduceMotion
275
+ selection.onSelect = onTabSelected
276
+ selection.onLongPress = onTabLongPress
277
+ selection.onTick = onTick
278
+ return selection
279
+ }
280
+
281
+ private func tabCell(_ index: Int, active: Bool) -> some View {
282
+ VStack(spacing: Spacing.XXS) {
283
+ DuoTabIcon(item: items[index], selected: active, badgeMaxWidth: cellWidth / 2 - Spacing.XXS)
284
+ MomoText(
285
+ items[index].label,
286
+ typography: .labelXsMedium,
287
+ color: active ? theme.colors.primary : theme.colors.text.hint,
288
+ textAlign: .center,
289
+ maxLines: 1
290
+ )
291
+ // like the system tab bar the label keeps its design size; a constant snapshot, nothing to observe
292
+ .environment(\.momoFontScale, FontScale(userScaleRate: 1, useOSScaleRate: false))
293
+ }
294
+ .frame(width: cellWidth)
295
+ .frame(maxHeight: .infinity)
296
+ }
297
+ }
298
+
299
+ /// One gesture over the whole bar drives the selection; SwiftUI drops `onEnded` when the system
300
+ /// cancels a touch, so the reset of the gesture state ends it instead.
301
+ private struct DuoLiquidInput: ViewModifier {
302
+ let selection: DuoLiquidSelection
303
+ let space: String
304
+
305
+ @GestureState private var touching = false
306
+
307
+ func body(content: Content) -> some View {
308
+ content
309
+ .coordinateSpace(name: space)
252
310
  .contentShape(Rectangle())
311
+ .gesture(
312
+ DragGesture(minimumDistance: 0, coordinateSpace: .named(space))
313
+ .updating($touching) { _, state, _ in state = true }
314
+ .onChanged { selection.touchChanged(location: $0.location, translation: $0.translation, time: $0.time) }
315
+ .onEnded { selection.touchEnded(location: $0.location) }
316
+ )
317
+ .onChange(of: touching) { if !$0 { selection.touchCancelled() } }
318
+ }
319
+ }
320
+
321
+ /// A tab drawn twice, inactive with the active layer over it fading in as the lens passes;
322
+ /// the tab the lens lands on pops.
323
+ private struct DuoLiquidTab<Content: View>: View {
324
+ @ObservedObject var selection: DuoLiquidSelection
325
+ let index: Int
326
+ let label: String
327
+ let selected: Bool
328
+ let onSelect: () -> Void
329
+ @ViewBuilder let content: (_ active: Bool) -> Content
330
+
331
+ var body: some View {
332
+ ZStack {
333
+ content(false)
334
+ content(true).opacity(selection.activeOpacity(index))
253
335
  }
254
- .buttonStyle(DuoLensPressStyle(index: index, pressedIndex: $pressedIndex))
255
- .accessibilityLabel(items[index].label)
336
+ .scaleEffect(selection.popIndex == index ? selection.pop : 1)
337
+ .accessibilityElement(children: .ignore)
338
+ .accessibilityLabel(label)
339
+ .accessibilityAddTraits(selected ? [.isButton, .isSelected] : .isButton)
340
+ .accessibilityAction { onSelect() }
256
341
  }
257
342
  }
258
343
 
259
- /// Pink tinted capsule under the selected tab; while pressed it grows into a lens (80x54 to about 96x70).
344
+ /// Pink tinted capsule under the selected tab.
260
345
  private struct DuoSelectionMaterial: View {
261
- let lens: Bool
262
-
263
346
  @Environment(\.appTheme) private var theme
264
347
 
265
348
  var body: some View {
266
349
  Capsule()
267
350
  .fill(theme.colors.primary.opacity(selectionTintOpacity))
268
- .scaleEffect(x: lens ? lensScaleX : 1, y: lens ? lensScaleY : 1)
269
351
  .allowsHitTesting(false)
270
352
  }
271
353
  }
272
354
 
273
- /// Tracks the finger so the selection lens jumps to the pressed tab before it is selected.
274
- private struct DuoLensPressStyle: ButtonStyle {
275
- let index: Int
276
- @Binding var pressedIndex: Int?
355
+ /// Lift of a single button, e.g. the compressed rail (RN `usePressLift`).
356
+ private struct DuoPressLiftStyle: ButtonStyle {
357
+ @Environment(\.accessibilityReduceMotion) private var reduceMotion
277
358
 
278
359
  func makeBody(configuration: Configuration) -> some View {
279
- configuration.label.onChange(of: configuration.isPressed) { pressed in
280
- if pressed {
281
- pressedIndex = index
282
- } else if pressedIndex == index {
283
- pressedIndex = nil
284
- }
285
- }
360
+ configuration.label
361
+ .scaleEffect(configuration.isPressed && !reduceMotion ? 1 + (liquidLensScaleX - 1) * 0.5 : 1)
362
+ .animation(liquidSpring, value: configuration.isPressed)
286
363
  }
287
364
  }
288
365
 
366
+ /// Floating bar: the badge starts at the icon's center and grows toward the cell's end edge.
367
+ /// Rail: it ends just inside the capsule and grows back over the icon (RN TabBarIcon `end: -3`).
368
+ /// Capped at `badgeMaxWidth` (an overlay alone would only offer it the icon's 28pt).
289
369
  private struct DuoTabIcon: View {
290
370
  let item: BottomTabItem
291
371
  let selected: Bool
372
+ let badgeMaxWidth: CGFloat
373
+ var inRail = false
292
374
 
293
375
  @Environment(\.appTheme) private var theme
294
376
 
@@ -300,10 +382,15 @@ private struct DuoTabIcon: View {
300
382
 
301
383
  var body: some View {
302
384
  Icon(source: item.icon, size: iconSize, color: selected ? theme.colors.primary : theme.colors.text.hint)
303
- .overlay(alignment: .topTrailing) {
385
+ .overlay(alignment: inRail ? .topTrailing : .topLeading) {
304
386
  if let badgeLabel, !badgeLabel.isEmpty {
305
- Badge(label: badgeLabel).offset(x: Spacing.S, y: -Spacing.XS)
306
- } else if badgeLabel != nil || item.showDot {
387
+ Badge(label: badgeLabel)
388
+ .frame(width: max(badgeMaxWidth, 0), alignment: inRail ? .trailing : .leading)
389
+ .offset(x: inRail ? railBadgeEndOffset : iconSize / 2, y: -Spacing.XS)
390
+ }
391
+ }
392
+ .overlay(alignment: .topTrailing) {
393
+ if badgeLabel?.isEmpty == true || (badgeLabel == nil && item.showDot) {
307
394
  BadgeDot(size: .small)
308
395
  }
309
396
  }
@@ -1,6 +1,6 @@
1
1
  Pod::Spec.new do |s|
2
2
  s.name = 'MoMoUIKits'
3
- s.version = '0.168.1-beta.24'
3
+ s.version = '0.168.1-beta.8'
4
4
  s.summary = 'MoMoUIKits for iOS'
5
5
  s.homepage = 'https://momo.vn'
6
6
  s.license = { :type => 'MIT' }
@@ -1,6 +1,6 @@
1
1
  Pod::Spec.new do |s|
2
2
  s.name = 'MoMoUIKitsDemo'
3
- s.version = '0.168.1-beta.24'
3
+ s.version = '0.168.1-beta.8'
4
4
  s.summary = 'Demo browser for MoMoUIKits SwiftUI components'
5
5
  s.homepage = 'https://momo.vn'
6
6
  s.license = { :type => 'MIT' }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@momo-kits/native-kits",
3
- "version": "0.168.1-beta.24-debug",
3
+ "version": "0.168.1-beta.25-debug",
4
4
  "private": false,
5
5
  "dependeNavigationncies": {},
6
6
  "devDependencies": {},
package/local.properties DELETED
@@ -1,8 +0,0 @@
1
- ## This file must *NOT* be checked into Version Control Systems,
2
- # as it contains information specific to your local configuration.
3
- #
4
- # Location of the SDK. This is only used by Gradle.
5
- # For customization when using a Version Control System, please read the
6
- # header note.
7
- #Wed Aug 21 14:20:12 ICT 2024
8
- sdk.dir=/Users/huynhdung/Library/Android/sdk