@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
|
@@ -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
|
-
//
|
|
24
|
-
private let
|
|
25
|
-
private let
|
|
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
|
-
|
|
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
|
-
@
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
153
|
-
|
|
163
|
+
let selection = configured()
|
|
164
|
+
let lens = selection.lensScale
|
|
165
|
+
return ZStack(alignment: .top) {
|
|
166
|
+
DuoSelectionMaterial()
|
|
154
167
|
.frame(width: railBubbleWidth, height: railBubbleHeight)
|
|
155
|
-
.
|
|
156
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
@
|
|
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
|
-
|
|
217
|
-
|
|
241
|
+
let selection = configured()
|
|
242
|
+
let lens = selection.lensScale
|
|
243
|
+
return ZStack(alignment: .leading) {
|
|
244
|
+
DuoSelectionMaterial()
|
|
218
245
|
.frame(width: cellWidth)
|
|
219
|
-
.
|
|
220
|
-
.
|
|
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
|
-
|
|
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
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
.
|
|
255
|
-
.
|
|
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
|
|
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
|
-
///
|
|
274
|
-
private struct
|
|
275
|
-
|
|
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
|
|
280
|
-
|
|
281
|
-
|
|
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)
|
|
306
|
-
|
|
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
|
}
|
package/ios/native-kits.podspec
CHANGED
package/package.json
CHANGED
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
|