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