@momo-kits/native-kits 0.168.1-beta.24 → 0.168.1-beta.25

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 CHANGED
@@ -77,6 +77,7 @@ Publishing is automated (`publish.sh` + GitLab CI on `[ci build]` / `main`). Do
77
77
  - iPhone Duo with a side strip ≥ 60pt: back + toolkit leave the bar for `HeaderSideBar` (`navigation/component/SideBar.kt`, 48pt glass controls); custom header-right stays in the bar. `sideBar.top` is 170pt in portrait, 120pt on the inner display in landscape (height ≥ 600), 24pt on the outer display in landscape, or below the header when the status bar is on top; UIKit's `statusBarFrame` is useless on Duo. Unlike RN the column is clamped above footer (24pt gap) / rail (12pt gap).
78
78
  - Reserved regions (iOS 27.1 `reservedRegionsOfKind:`, reached through the ObjC runtime so older SDKs still build): a probe `UIView` (`platform/ReservedRegions.ios.kt` on the Compose root view; `ReservedRegionsProbe` inside SwiftUI `momoScreenLayout()`) re-reads them in `layoutSubviews`, which UIKit re-runs when they change. They push `sideBar.top` below the status cluster as it grows for calls / Live Activities, and `sideBar.bottom` (≥ 24pt) above a camera at the bottom end of the strip.
79
79
  - Tab bar on Duo is `bottomtab/DuoTabBar.kt`: a rail in the side strip (`sideBar.bottom` above the window bottom, QR below it) or, without a side bar, a floating bar spanning 6/8 grid columns. Both overlay the content; `duoFloatingTabBarInset` pads tab content, footer and FAB.
80
+ - Duo tab selection motion (`bottomtab/DuoLiquidSelection.kt` / `ios/Navigation/BottomTab/DuoLiquidSelection.swift`, port of RN `useLiquidSelection.ts`, momo-kits !682): press lifts the lens, a drag past 10pt takes over (Compose: the bar's `pointerInput` consumes and so cancels the tab's clickable; SwiftUI: one `DragGesture` over the bar, tabs are not `Button`s), long press calls `BottomTab(onTabLongPress)` and release still selects, Reduce Motion (`platform/ReduceMotion`) falls back to a plain slide. SwiftUI integrates the lens spring on a display link because the stretch and the tabs' active tint read it every frame. Constants mirror RN — change both together.
80
81
  - Bar compression (`bottomtab/DuoBarCompression.kt`, HIG "Vertical controls"): when toolbar + rail don't fit in the strip, one is compressed — the toolkit into a `navigation_menu` button, the rail into one button showing the current tab — and tapping it swaps them; `BottomTab(compressionBehavior)` picks which starts compressed. If neither fits beside the other compressed, both rest compressed and a tap opens one for a moment. Side bar columns report their full height (0 when the screen puts nothing there) so the decision follows each screen's toolkit.
81
82
  - Glass header: on Duo with a side bar, Default/Extended headers without search and with a `ScrollState` let content scroll under the bar (`usesDuoGlassHeader`); the bar fades in a translucent surface + hairline over 16pt (Compose has no backdrop blur — RN's fallback look), the gradient leaves with the content.
82
83
  - Quick return header: glass headers and Animated headers (with a `ScrollState`) on the Duo side bar layout slide out with the content and back in on a reverse scroll; a half-shown header settles in or out once the scroll stops. The side bar is drawn apart from the header, so it stays put.
@@ -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
 
@@ -0,0 +1,364 @@
1
+ //
2
+ // DuoLiquidSelection.swift
3
+ // MoMoUIKits
4
+ //
5
+ // iOS 26 tab bar selection motion without the glass material, port of RN
6
+ // `BottomTab/useLiquidSelection.ts` (Compose `DuoLiquidSelection.kt`).
7
+ //
8
+
9
+ import SwiftUI
10
+ import UIKit
11
+
12
+ enum LiquidAxis {
13
+ case x, y
14
+ }
15
+
16
+ // RN reanimated { damping: 18, stiffness: 220, mass: 0.8 }
17
+ let liquidSpring = Animation.interpolatingSpring(mass: 0.8, stiffness: 220, damping: 18)
18
+
19
+ private struct SpringParams {
20
+ let mass: CGFloat
21
+ let stiffness: CGFloat
22
+ let damping: CGFloat
23
+
24
+ static let standard = SpringParams(mass: 0.8, stiffness: 220, damping: 18)
25
+ // the lens trails the finger a little while dragged
26
+ static let drag = SpringParams(mass: 0.6, stiffness: 600, damping: 24)
27
+ }
28
+
29
+ private let reducedDuration: CFTimeInterval = 0.2
30
+ // while pressed the selection bubble grows into a lens past the bar's edges (80x54 to about 96x70)
31
+ let liquidLensScaleX: CGFloat = 1.2
32
+ let liquidLensScaleY: CGFloat = 1.3
33
+ // a moving lens stretches along its path and thins across it, eased per frame
34
+ private let stretchPerPoint: CGFloat = 0.012
35
+ private let maxStretch: CGFloat = 0.25
36
+ private let stretchEasing: CGFloat = 0.25
37
+ private let restStretch: CGFloat = 0.002
38
+ // a tap is shorter than the lift: the lens finishes rising, then settles, one smooth pulse
39
+ private let tapLiftDuration: Double = 0.12
40
+ private let barSwell: CGFloat = 0.04
41
+ private let iconPop: CGFloat = 1.15
42
+ private let iconPopDuration: Double = 0.12
43
+ // below it a press is a tap
44
+ private let dragSlop: CGFloat = 10
45
+ // UIScrollView rubber band constant
46
+ private let rubberBandConstant: CGFloat = 0.55
47
+ // a vetoed switch: the lens returns to the selected tab after this
48
+ private let resyncDelay: Double = 0.1
49
+ // RN TouchableOpacity delayLongPress
50
+ private let longPressDelay: Double = 0.5
51
+
52
+ /// Resistance past the first and last tab, the further the finger goes the less the lens follows.
53
+ private func rubberBand(_ value: CGFloat, max: CGFloat, dimension: CGFloat) -> CGFloat {
54
+ func band(_ overshoot: CGFloat) -> CGFloat {
55
+ (1 - 1 / (overshoot * rubberBandConstant / dimension + 1)) * dimension
56
+ }
57
+ if value < 0 { return -band(-value) }
58
+ if value > max { return max + band(value - max) }
59
+ return value
60
+ }
61
+
62
+ /// Owns the lens: its offset is integrated on a display link (SwiftUI can't expose an animation's
63
+ /// in-flight value, and the stretch and the tabs' active tint follow it every frame); lift and pop
64
+ /// are plain SwiftUI springs. Input is one gesture over the whole bar, so a press is either a tap,
65
+ /// a long press or a drag.
66
+ final class DuoLiquidSelection: ObservableObject {
67
+ @Published private(set) var offset: CGFloat = 0
68
+ @Published private(set) var stretch: CGFloat = 0
69
+ @Published private(set) var lift: CGFloat = 0
70
+ @Published private(set) var selectedIndex = 0
71
+ @Published private(set) var popIndex: Int?
72
+ @Published private(set) var pop: CGFloat = 1
73
+
74
+ var axis: LiquidAxis = .x
75
+ var count = 0
76
+ /// Distance between two neighbouring tab centers.
77
+ var pitch: CGFloat = 0
78
+ /// The first tab's center in the gesture's coordinate space.
79
+ var firstCenter: CGFloat = 0
80
+ var reduceMotion = false
81
+ var onSelect: (Int) -> Void = { _ in }
82
+ var onLongPress: ((Int) -> Void)?
83
+ var onTick: () -> Void = {}
84
+
85
+ private var velocity: CGFloat = 0
86
+ // where the lens is heading, so a re-render doesn't restart a spring already going there
87
+ private var destination: CGFloat = 0
88
+ private var spring = SpringParams.standard
89
+ private var timed: (from: CGFloat, start: CFTimeInterval?)?
90
+ private var displayLink: CADisplayLink?
91
+ private var lastTimestamp: CFTimeInterval?
92
+ private var measured = false
93
+
94
+ private var touchIndex: Int?
95
+ private var dragging = false
96
+ private var hovered = 0
97
+ private var longPressWork: DispatchWorkItem?
98
+ private var resyncWork: DispatchWorkItem?
99
+ private var lastSample: (position: CGFloat, time: Date)?
100
+ private var dragVelocity: CGFloat = 0
101
+ private var liftGeneration = 0
102
+ private var popGeneration = 0
103
+
104
+ deinit {
105
+ displayLink?.invalidate()
106
+ }
107
+
108
+ func sync(index: Int) {
109
+ let changed = index != selectedIndex
110
+ selectedIndex = index
111
+ let target = CGFloat(index) * pitch
112
+ // snap on first layout so the lens doesn't slide in from the leading edge
113
+ if measured {
114
+ move(to: target)
115
+ if changed { popIcon(index) }
116
+ } else {
117
+ stop()
118
+ destination = target
119
+ offset = target
120
+ }
121
+ measured = pitch > 0
122
+ }
123
+
124
+ func activeOpacity(_ index: Int) -> Double {
125
+ guard pitch > 0 else { return selectedIndex == index ? 1 : 0 }
126
+ return Double(min(max(1 - abs(offset - CGFloat(index) * pitch) / pitch, 0), 1))
127
+ }
128
+
129
+ var barScale: CGFloat { 1 + lift * barSwell }
130
+
131
+ var lensScale: (along: CGFloat, across: CGFloat, x: CGFloat, y: CGFloat) {
132
+ (1 + stretch, 1 - stretch * 0.5, 1 + lift * (liquidLensScaleX - 1), 1 + lift * (liquidLensScaleY - 1))
133
+ }
134
+
135
+ // MARK: Input
136
+
137
+ func touchChanged(location: CGPoint, translation: CGSize, time: Date) {
138
+ let value = position(location)
139
+ defer { lastSample = (value, time) }
140
+ guard let touchIndex else {
141
+ let index = nearest(value)
142
+ touchIndex = index
143
+ dragging = false
144
+ dragVelocity = 0
145
+ pressIn(index)
146
+ let work = DispatchWorkItem { [weak self] in self?.onLongPress?(index) }
147
+ longPressWork = work
148
+ DispatchQueue.main.asyncAfter(deadline: .now() + longPressDelay, execute: work)
149
+ return
150
+ }
151
+ if !dragging, hypot(translation.width, translation.height) > dragSlop {
152
+ dragging = true
153
+ longPressWork?.cancel()
154
+ hovered = touchIndex
155
+ animateLift(1)
156
+ }
157
+ guard dragging else { return }
158
+ if let lastSample {
159
+ let dt = time.timeIntervalSince(lastSample.time)
160
+ if dt > 0 { dragVelocity = (value - lastSample.position) / CGFloat(dt) }
161
+ }
162
+ let target = nearest(value)
163
+ if target != hovered {
164
+ hovered = target
165
+ onTick()
166
+ }
167
+ let follow = rubberBand(value, max: CGFloat(count - 1) * pitch, dimension: pitch)
168
+ if reduceMotion {
169
+ stop()
170
+ destination = follow
171
+ offset = follow
172
+ } else {
173
+ move(to: follow, spring: .drag)
174
+ }
175
+ }
176
+
177
+ func touchEnded(location: CGPoint) {
178
+ longPressWork?.cancel()
179
+ longPressWork = nil
180
+ defer {
181
+ touchIndex = nil
182
+ lastSample = nil
183
+ }
184
+ guard let touchIndex else { return }
185
+ if dragging {
186
+ dragging = false
187
+ animateLift(0)
188
+ let target = nearest(position(location))
189
+ move(to: CGFloat(target) * pitch, velocity: dragVelocity)
190
+ select(target)
191
+ } else {
192
+ pulseLift()
193
+ // a tap or a held tab, both pick the pressed tab like the system tab bar
194
+ select(touchIndex)
195
+ }
196
+ }
197
+
198
+ /// The system took the touch: nothing is selected, the lens returns to the selected tab.
199
+ func touchCancelled() {
200
+ guard touchIndex != nil else { return }
201
+ longPressWork?.cancel()
202
+ longPressWork = nil
203
+ touchIndex = nil
204
+ lastSample = nil
205
+ dragging = false
206
+ animateLift(0)
207
+ move(to: CGFloat(selectedIndex) * pitch, velocity: 0)
208
+ }
209
+
210
+ // MARK: Motion
211
+
212
+ private func position(_ point: CGPoint) -> CGFloat {
213
+ (axis == .x ? point.x : point.y) - firstCenter
214
+ }
215
+
216
+ private func nearest(_ value: CGFloat) -> Int {
217
+ guard pitch > 0 else { return 0 }
218
+ return min(max(Int((value / pitch).rounded()), 0), max(count - 1, 0))
219
+ }
220
+
221
+ private func pressIn(_ index: Int) {
222
+ resyncWork?.cancel()
223
+ // the lens heads to the pressed tab, not only the selected one
224
+ move(to: CGFloat(index) * pitch)
225
+ animateLift(1)
226
+ }
227
+
228
+ private func select(_ index: Int) {
229
+ onSelect(index)
230
+ resync()
231
+ }
232
+
233
+ /// Returns the lens to the selected tab unless a switch to where it is heading happened.
234
+ private func resync() {
235
+ resyncWork?.cancel()
236
+ let work = DispatchWorkItem { [weak self] in
237
+ guard let self, !self.dragging else { return }
238
+ let target = CGFloat(self.selectedIndex) * self.pitch
239
+ if self.destination != target { self.move(to: target) }
240
+ }
241
+ resyncWork = work
242
+ DispatchQueue.main.asyncAfter(deadline: .now() + resyncDelay, execute: work)
243
+ }
244
+
245
+ private func animateLift(_ target: CGFloat) {
246
+ liftGeneration += 1
247
+ if reduceMotion {
248
+ lift = 0
249
+ return
250
+ }
251
+ withAnimation(liquidSpring) { lift = target }
252
+ }
253
+
254
+ private func pulseLift() {
255
+ guard !reduceMotion else { return animateLift(0) }
256
+ liftGeneration += 1
257
+ let generation = liftGeneration
258
+ withAnimation(.linear(duration: tapLiftDuration)) { lift = 1 }
259
+ DispatchQueue.main.asyncAfter(deadline: .now() + tapLiftDuration) { [weak self] in
260
+ guard let self, self.liftGeneration == generation else { return }
261
+ withAnimation(liquidSpring) { self.lift = 0 }
262
+ }
263
+ }
264
+
265
+ private func popIcon(_ index: Int) {
266
+ guard !reduceMotion else { return }
267
+ popGeneration += 1
268
+ let generation = popGeneration
269
+ popIndex = index
270
+ pop = 1
271
+ withAnimation(.linear(duration: iconPopDuration)) { pop = iconPop }
272
+ DispatchQueue.main.asyncAfter(deadline: .now() + iconPopDuration) { [weak self] in
273
+ guard let self, self.popGeneration == generation else { return }
274
+ withAnimation(liquidSpring) { self.pop = 1 }
275
+ }
276
+ }
277
+
278
+ private func move(to target: CGFloat, velocity: CGFloat? = nil, spring: SpringParams = .standard) {
279
+ if destination == target, velocity == nil { return }
280
+ destination = target
281
+ self.spring = spring
282
+ if let velocity { self.velocity = velocity }
283
+ timed = reduceMotion ? (from: offset, start: nil) : nil
284
+ startDisplayLink()
285
+ }
286
+
287
+ private func stop() {
288
+ velocity = 0
289
+ timed = nil
290
+ }
291
+
292
+ fileprivate func tick(_ link: CADisplayLink) {
293
+ let now = link.targetTimestamp
294
+ let dt = min(now - (lastTimestamp ?? link.timestamp), 1.0 / 30)
295
+ lastTimestamp = now
296
+ let previous = offset
297
+ var next = offset
298
+ var settled = false
299
+
300
+ if var timed {
301
+ let start = timed.start ?? link.timestamp
302
+ timed.start = start
303
+ self.timed = timed
304
+ let fraction = min(max((now - start) / reducedDuration, 0), 1)
305
+ // reanimated withTiming default: ease in-out quad
306
+ let eased = fraction < 0.5 ? 2 * fraction * fraction : 1 - 2 * (1 - fraction) * (1 - fraction)
307
+ next = timed.from + (destination - timed.from) * CGFloat(eased)
308
+ settled = fraction >= 1
309
+ } else {
310
+ // a few sub steps keep the stiff drag spring stable
311
+ let steps = 4
312
+ let h = CGFloat(dt) / CGFloat(steps)
313
+ for _ in 0..<steps {
314
+ let force = -spring.stiffness * (next - destination) - spring.damping * velocity
315
+ velocity += force / spring.mass * h
316
+ next += velocity * h
317
+ }
318
+ settled = abs(next - destination) < 0.1 && abs(velocity) < 1
319
+ }
320
+ if settled {
321
+ next = destination
322
+ stop()
323
+ }
324
+ offset = next
325
+
326
+ if reduceMotion {
327
+ stretch = 0
328
+ } else {
329
+ let target = min(abs(next - previous) * stretchPerPoint, maxStretch)
330
+ // eased both ways: a slow frame (the spring turning at its overshoot) must not snap the shape
331
+ let eased = stretch + (target - stretch) * stretchEasing
332
+ stretch = eased < restStretch ? 0 : eased
333
+ }
334
+ if settled, stretch == 0 { stopDisplayLink() }
335
+ }
336
+
337
+ private func startDisplayLink() {
338
+ guard displayLink == nil else { return }
339
+ let link = CADisplayLink(target: DisplayLinkProxy(owner: self), selector: #selector(DisplayLinkProxy.tick))
340
+ link.preferredFrameRateRange = CAFrameRateRange(minimum: 60, maximum: 120, preferred: 120)
341
+ link.add(to: .main, forMode: .common)
342
+ displayLink = link
343
+ lastTimestamp = nil
344
+ }
345
+
346
+ private func stopDisplayLink() {
347
+ displayLink?.invalidate()
348
+ displayLink = nil
349
+ }
350
+ }
351
+
352
+ /// CADisplayLink retains its target; the proxy keeps the selection free to deinit mid motion.
353
+ private final class DisplayLinkProxy: NSObject {
354
+ weak var owner: DuoLiquidSelection?
355
+
356
+ init(owner: DuoLiquidSelection) {
357
+ self.owner = owner
358
+ }
359
+
360
+ @objc func tick(_ link: CADisplayLink) {
361
+ guard let owner else { return link.invalidate() }
362
+ owner.tick(link)
363
+ }
364
+ }
@@ -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",
3
+ "version": "0.168.1-beta.25",
4
4
  "private": false,
5
5
  "dependeNavigationncies": {},
6
6
  "devDependencies": {},