@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
|
@@ -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
|
+
}
|