@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 +1 -0
- 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/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
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
//
|
|
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