@momo-kits/native-kits 0.167.1-duo.7 → 0.167.1-duo.8
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 +7 -4
- package/ios/Application/Layout/ScreenLayout.swift +122 -6
- package/ios/Application/Navigation/BottomTab/BottomTab.swift +41 -5
- package/ios/Application/Navigation/BottomTab/DuoBarCompression.swift +105 -0
- package/ios/Application/Navigation/BottomTab/DuoTabBar.swift +60 -31
- package/ios/Application/Navigation/component/DuoScrollGradient.swift +18 -19
- package/ios/Application/Navigation/component/Header.swift +3 -2
- package/ios/Application/Navigation/component/NavigationHeaderRight.swift +32 -0
- package/ios/Application/Navigation/component/SideBar.swift +2 -2
- package/ios/Application/StackScreen.swift +46 -6
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/DuoTabDemo.swift +7 -3
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/IPhoneDuoDemo.swift +3 -0
- package/package.json +1 -1
package/CLAUDE.md
CHANGED
|
@@ -65,10 +65,13 @@ Publishing is automated (`publish.sh` + GitLab CI on `[ci build]` / `main`). Do
|
|
|
65
65
|
|
|
66
66
|
- `layout/ScreenLayout.kt`: `getScreenLayout()` returns window size, raw top/bottom + side insets, `contentWidth`, header geometry, the iPhone Duo `sideBar` and `duoTopInset`. `NavigationContainer` computes it once and provides `LocalScreenLayout`; reactive to fold/unfold and rotation (window size + `WindowInsets`). Metrics (`DuoMetrics`, 72pt iPhone header floor, 20pt status-bar threshold) mirror RN `Layout/utils.ts` — change both together.
|
|
67
67
|
- iPhone-only: everything gates on `isPhoneIdiom()` (`platform/`), never on "iOS", so iPad windows keep the regular layout. Two detectors, as in RN: `isIPhoneDuo` (window aspect < 1.6 or asymmetric side insets) drives layout; `isIPhoneDuoDevice` (short side of screen or window > 440, RN `device.ts`) drives popups, modal and bottom sheet.
|
|
68
|
-
- Header height is `headerHeight` (not `AppStatusBar + HEADER_HEIGHT`); the bar row is `headerBarHeight
|
|
69
|
-
- 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.
|
|
70
|
-
-
|
|
71
|
-
-
|
|
68
|
+
- Header height is `headerHeight` (not `AppStatusBar + HEADER_HEIGHT`); the bar row is `headerBarHeight` and starts `headerStatusBarHeight` below the window top. When the status bar is off the top edge (iPhone landscape, iPhone Duo) `headerStatusBarHeight` is 0 so the title stays centered — except on the Duo outer display in portrait, where the header is 76pt and the row starts at 14pt so the title centers on the camera punchout.
|
|
69
|
+
- 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).
|
|
70
|
+
- 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.
|
|
71
|
+
- 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.
|
|
72
|
+
- 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 so the decision follows each screen's toolkit.
|
|
73
|
+
- 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.
|
|
74
|
+
- 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.
|
|
72
75
|
- Overlays on Duo: popups cap at 440pt wide and the room between `duoTopInset` and the bottom inset (body scrolls, actions stay); modal centers inside that room; bottom sheet uses the window height, centers at width ≥ 600 and pads by side insets in landscape.
|
|
73
76
|
- Font scale deliberately uses the short side (`min(w, h)`), unlike RN which reads the window width — landscape must not inflate every font.
|
|
74
77
|
- `StackScreen` pads content/footer/search/FAB by the side insets and provides an inset-consumed layout to its content; a bottom-tab root stays full width (tab bar + each tab's StackScreen handle insets). Grid columns use `contentWidth`.
|
|
@@ -18,6 +18,8 @@ public struct SideBarLayout: Equatable {
|
|
|
18
18
|
public let width: CGFloat
|
|
19
19
|
/// Preferred window y of the first control; StackScreen still keeps the column above the bottom bars.
|
|
20
20
|
public let top: CGFloat
|
|
21
|
+
/// Room kept free above the window bottom, clear of a camera at the bottom end of the strip.
|
|
22
|
+
public let bottom: CGFloat
|
|
21
23
|
}
|
|
22
24
|
|
|
23
25
|
public struct ScreenLayout: Equatable {
|
|
@@ -47,6 +49,13 @@ enum DuoMetrics {
|
|
|
47
49
|
static let itemPitch: CGFloat = 50
|
|
48
50
|
static let firstItemTop: CGFloat = 170
|
|
49
51
|
static let firstItemTopLandscape: CGFloat = 120
|
|
52
|
+
// Outer display in portrait: the header is 4pt taller than the iPhone floor and its title
|
|
53
|
+
// lines up with the camera punchout (y 24–66 at the top of the strip).
|
|
54
|
+
static let outerHeaderHeight: CGFloat = 76
|
|
55
|
+
static let outerCameraCenterY: CGFloat = 45
|
|
56
|
+
// Outer display in landscape has no status bar in the strip: controls keep this far from
|
|
57
|
+
// both ends unless a reserved region (the camera at either end) asks for more.
|
|
58
|
+
static let edgeInset: CGFloat = 24
|
|
50
59
|
// Widest short side of any single-display iPhone.
|
|
51
60
|
static let iPhoneMaxShortSide: CGFloat = 440
|
|
52
61
|
}
|
|
@@ -79,10 +88,15 @@ extension ScreenLayout {
|
|
|
79
88
|
)
|
|
80
89
|
}
|
|
81
90
|
|
|
82
|
-
static func make(
|
|
91
|
+
static func make(
|
|
92
|
+
size: CGSize,
|
|
93
|
+
insets: EdgeInsets,
|
|
94
|
+
reservedRegions: [CGRect] = ReservedRegions.current,
|
|
95
|
+
isPhone: Bool = isPhoneIdiom
|
|
96
|
+
) -> ScreenLayout {
|
|
83
97
|
let width = size.width
|
|
84
98
|
let height = size.height
|
|
85
|
-
|
|
99
|
+
var (headerHeight, headerStatusBarHeight) = headerGeometry(topInset: insets.top, isPhone: isPhone)
|
|
86
100
|
let isDuo = isPhone && isIPhoneDuoWindow(width: width, height: height, insets: insets)
|
|
87
101
|
guard isDuo else {
|
|
88
102
|
return ScreenLayout(
|
|
@@ -99,13 +113,32 @@ extension ScreenLayout {
|
|
|
99
113
|
var top = insets.top + HeaderMetrics.barHeight + Spacing.M
|
|
100
114
|
if insets.top < HeaderMetrics.statusBarMinInset {
|
|
101
115
|
// status bar lives in the strip: start under its cluster, as the native bar does
|
|
102
|
-
|
|
103
|
-
|
|
116
|
+
if width <= height {
|
|
117
|
+
top = DuoMetrics.firstItemTop
|
|
118
|
+
} else if height >= DuoMetrics.innerMinHeight {
|
|
119
|
+
top = DuoMetrics.firstItemTopLandscape
|
|
120
|
+
} else {
|
|
121
|
+
// outer display in landscape: no status bar in the strip
|
|
122
|
+
top = DuoMetrics.edgeInset
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if height > width && width < DuoMetrics.innerMinHeight {
|
|
126
|
+
// outer display: the row starts below the top so the title centers on the camera
|
|
127
|
+
headerHeight = DuoMetrics.outerHeaderHeight
|
|
128
|
+
headerStatusBarHeight = DuoMetrics.outerCameraCenterY * 2 - DuoMetrics.outerHeaderHeight
|
|
129
|
+
}
|
|
104
130
|
}
|
|
131
|
+
|
|
132
|
+
let edge: SideBarEdge = insets.trailing >= insets.leading ? .right : .left
|
|
133
|
+
let stripLeft = edge == .right ? width - sideInset : 0
|
|
134
|
+
// clear of the camera and of the status bar cluster, which grows for calls and Live
|
|
135
|
+
// Activities; region frames already include the system's margin
|
|
136
|
+
let occlusion = stripOcclusion(reservedRegions, stripLeft: stripLeft, stripRight: stripLeft + sideInset, windowHeight: height)
|
|
105
137
|
sideBar = SideBarLayout(
|
|
106
|
-
edge:
|
|
138
|
+
edge: edge,
|
|
107
139
|
width: sideInset,
|
|
108
|
-
top: top
|
|
140
|
+
top: max(top, occlusion.top),
|
|
141
|
+
bottom: max(DuoMetrics.edgeInset, occlusion.bottom)
|
|
109
142
|
)
|
|
110
143
|
}
|
|
111
144
|
|
|
@@ -126,6 +159,24 @@ extension ScreenLayout {
|
|
|
126
159
|
)
|
|
127
160
|
}
|
|
128
161
|
|
|
162
|
+
/// How far system regions reach into a side strip from its top and from its bottom end
|
|
163
|
+
/// (e.g. the camera, which sits at either end depending on the rotation).
|
|
164
|
+
private static func stripOcclusion(
|
|
165
|
+
_ regions: [CGRect],
|
|
166
|
+
stripLeft: CGFloat,
|
|
167
|
+
stripRight: CGFloat,
|
|
168
|
+
windowHeight: CGFloat
|
|
169
|
+
) -> (top: CGFloat, bottom: CGFloat) {
|
|
170
|
+
regions.reduce(into: (top: CGFloat(0), bottom: CGFloat(0))) { occlusion, region in
|
|
171
|
+
guard region.minX < stripRight && region.maxX > stripLeft else { return }
|
|
172
|
+
if region.midY < windowHeight / 2 {
|
|
173
|
+
occlusion.top = max(occlusion.top, region.maxY)
|
|
174
|
+
} else {
|
|
175
|
+
occlusion.bottom = max(occlusion.bottom, windowHeight - region.minY)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
129
180
|
// Slab iPhones report equal side insets in landscape; iPhone Duo moves the status bar to one side.
|
|
130
181
|
private static func isIPhoneDuoWindow(width: CGFloat, height: CGFloat, insets: EdgeInsets) -> Bool {
|
|
131
182
|
let shortSide = min(width, height)
|
|
@@ -148,6 +199,70 @@ public func isIPhoneDuoDevice(windowSize: CGSize? = nil) -> Bool {
|
|
|
148
199
|
return max(min(screen.width, screen.height), min(window.width, window.height)) > DuoMetrics.iPhoneMaxShortSide
|
|
149
200
|
}
|
|
150
201
|
|
|
202
|
+
// MARK: - Reserved regions
|
|
203
|
+
|
|
204
|
+
/// System occlusion regions (iOS 27.1+) in window coordinates: the iPhone Duo camera, and the
|
|
205
|
+
/// status bar cluster as it grows for a call or a Live Activity. Empty before iOS 27.1.
|
|
206
|
+
enum ReservedRegions {
|
|
207
|
+
// last report of the probe, so `ScreenLayout.current` includes it
|
|
208
|
+
static var current: [CGRect] = []
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/// Invisible probe: reading the regions in layoutSubviews makes UIKit run it again when they
|
|
212
|
+
/// change, so nothing polls. Port of RN `@momo-kits/reserved-regions`.
|
|
213
|
+
private final class ReservedRegionsProbe: UIView {
|
|
214
|
+
var onChange: (() -> Void)?
|
|
215
|
+
private var last: [CGRect]?
|
|
216
|
+
|
|
217
|
+
override init(frame: CGRect) {
|
|
218
|
+
super.init(frame: frame)
|
|
219
|
+
isUserInteractionEnabled = false
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
required init?(coder: NSCoder) { nil }
|
|
223
|
+
|
|
224
|
+
override func layoutSubviews() {
|
|
225
|
+
super.layoutSubviews()
|
|
226
|
+
let regions = occlusionRegions()
|
|
227
|
+
guard regions != last else { return }
|
|
228
|
+
last = regions
|
|
229
|
+
ReservedRegions.current = regions
|
|
230
|
+
// layout pass: publish on the next turn instead of mutating SwiftUI state mid-update
|
|
231
|
+
DispatchQueue.main.async { [weak self] in self?.onChange?() }
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
override func didMoveToWindow() {
|
|
235
|
+
super.didMoveToWindow()
|
|
236
|
+
setNeedsLayout()
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// The API is newer than the SDKs hosts may build with, so it is reached through the runtime.
|
|
240
|
+
private func occlusionRegions() -> [CGRect] {
|
|
241
|
+
let regionsOfKind = NSSelectorFromString("reservedRegionsOfKind:")
|
|
242
|
+
guard window != nil, responds(to: regionsOfKind),
|
|
243
|
+
let kindType = NSClassFromString("UIViewReservedRegionKind") as? NSObject.Type,
|
|
244
|
+
let kind = (kindType as AnyObject).perform(NSSelectorFromString("occlusionRegionKind"))?.takeUnretainedValue(),
|
|
245
|
+
let regions = perform(regionsOfKind, with: kind)?.takeUnretainedValue() as? [NSObject]
|
|
246
|
+
else { return [] }
|
|
247
|
+
return regions.compactMap { ($0.value(forKey: "frame") as? NSValue)?.cgRectValue }
|
|
248
|
+
.map { convert($0, to: nil) }
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
private struct ReservedRegionsProbeView: UIViewRepresentable {
|
|
253
|
+
let onChange: () -> Void
|
|
254
|
+
|
|
255
|
+
func makeUIView(context: Context) -> ReservedRegionsProbe {
|
|
256
|
+
let probe = ReservedRegionsProbe()
|
|
257
|
+
probe.onChange = onChange
|
|
258
|
+
return probe
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
func updateUIView(_ probe: ReservedRegionsProbe, context: Context) {
|
|
262
|
+
probe.onChange = onChange
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
151
266
|
// MARK: - Environment
|
|
152
267
|
|
|
153
268
|
private struct ScreenLayoutKey: EnvironmentKey {
|
|
@@ -177,6 +292,7 @@ private struct ScreenLayoutReader: ViewModifier {
|
|
|
177
292
|
.onChange(of: proxy.safeAreaInsets) { _ in refresh() }
|
|
178
293
|
}
|
|
179
294
|
)
|
|
295
|
+
.background(ReservedRegionsProbeView { refresh() }.ignoresSafeArea())
|
|
180
296
|
.onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in
|
|
181
297
|
refresh()
|
|
182
298
|
}
|
|
@@ -14,14 +14,25 @@ import SwiftUI
|
|
|
14
14
|
public struct BottomTab: View {
|
|
15
15
|
private let items: [BottomTabItem]
|
|
16
16
|
private let floatingButton: BottomTabFloatingButton?
|
|
17
|
+
private let compressionBehavior: DuoBarCompressionBehavior
|
|
17
18
|
|
|
18
19
|
@EnvironmentObject private var navigator: Navigator
|
|
19
20
|
@Environment(\.screenLayout) private var layout
|
|
20
21
|
@State private var selectedIndex: Int = 0
|
|
22
|
+
@State private var toolbarHeight = DuoBarCompression.defaultToolbarHeight
|
|
23
|
+
@State private var expandedBar: DuoBar?
|
|
24
|
+
@State private var openedBar: DuoBar?
|
|
21
25
|
|
|
22
|
-
|
|
26
|
+
/// `compressionBehavior`: on iPhone Duo, when the side bar can't hold both bars, which one
|
|
27
|
+
/// starts compressed; tapping the compressed one swaps them.
|
|
28
|
+
public init(
|
|
29
|
+
items: [BottomTabItem],
|
|
30
|
+
floatingButton: BottomTabFloatingButton? = nil,
|
|
31
|
+
compressionBehavior: DuoBarCompressionBehavior = .toolbar
|
|
32
|
+
) {
|
|
23
33
|
self.items = items
|
|
24
34
|
self.floatingButton = floatingButton
|
|
35
|
+
self.compressionBehavior = compressionBehavior
|
|
25
36
|
}
|
|
26
37
|
|
|
27
38
|
private var bottomInset: CGFloat { safeAreaBottomInset() }
|
|
@@ -30,11 +41,34 @@ public struct BottomTab: View {
|
|
|
30
41
|
layout.isIPhoneDuo ? layout.insets.bottom : BOTTOM_TAB_BAR_HEIGHT + bottomInset + Spacing.S
|
|
31
42
|
}
|
|
32
43
|
|
|
33
|
-
private var
|
|
34
|
-
|
|
44
|
+
private var compression: DuoBarCompression {
|
|
45
|
+
DuoBarCompression.resolve(
|
|
46
|
+
layout: layout,
|
|
47
|
+
tabCount: items.count,
|
|
48
|
+
hasQr: floatingButton != nil,
|
|
49
|
+
toolbarHeight: toolbarHeight,
|
|
50
|
+
// 'toolbar' compression keeps the tab bar whole until the toolbar is tapped
|
|
51
|
+
expanded: Binding(
|
|
52
|
+
get: { expandedBar ?? (compressionBehavior == .toolbar ? .tabBar : .toolbar) },
|
|
53
|
+
set: { expandedBar = $0 }
|
|
54
|
+
),
|
|
55
|
+
opened: $openedBar,
|
|
56
|
+
setToolbarHeight: { height in
|
|
57
|
+
if toolbarHeight != height { toolbarHeight = height }
|
|
58
|
+
}
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private func duoInsets(_ compression: DuoBarCompression) -> DuoTabBarInsets {
|
|
63
|
+
layout.duoTabBarInsets(
|
|
64
|
+
itemCount: items.count,
|
|
65
|
+
hasFloatingButton: floatingButton != nil,
|
|
66
|
+
tabBarCompressed: compression.tabBarCompressed
|
|
67
|
+
)
|
|
35
68
|
}
|
|
36
69
|
|
|
37
70
|
public var body: some View {
|
|
71
|
+
let compression = compression
|
|
38
72
|
ZStack(alignment: .bottom) {
|
|
39
73
|
// Tab content area, inset above the bar.
|
|
40
74
|
ZStack {
|
|
@@ -46,7 +80,8 @@ public struct BottomTab: View {
|
|
|
46
80
|
}
|
|
47
81
|
}
|
|
48
82
|
.padding(.bottom, reservedBottom)
|
|
49
|
-
.environment(\.duoTabBarInsets, duoInsets)
|
|
83
|
+
.environment(\.duoTabBarInsets, duoInsets(compression))
|
|
84
|
+
.environment(\.duoBarCompression, compression)
|
|
50
85
|
.animation(.easeInOut(duration: 0.2), value: selectedIndex)
|
|
51
86
|
|
|
52
87
|
if layout.isIPhoneDuo {
|
|
@@ -54,7 +89,8 @@ public struct BottomTab: View {
|
|
|
54
89
|
items: items,
|
|
55
90
|
floatingButton: floatingButton,
|
|
56
91
|
selectedIndex: selectedIndex,
|
|
57
|
-
onTabSelected: selectTab
|
|
92
|
+
onTabSelected: selectTab,
|
|
93
|
+
compression: compression
|
|
58
94
|
)
|
|
59
95
|
} else {
|
|
60
96
|
// Tab bar + home-indicator filler.
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
//
|
|
2
|
+
// DuoBarCompression.swift
|
|
3
|
+
// MoMoUIKits
|
|
4
|
+
//
|
|
5
|
+
// Port of RN `BottomTab/DuoBarCompression.tsx` (HIG, Designing for iPhone Duo: Vertical controls):
|
|
6
|
+
// keeps the side bar's toolbar and tab bar clear of each other.
|
|
7
|
+
//
|
|
8
|
+
|
|
9
|
+
import SwiftUI
|
|
10
|
+
|
|
11
|
+
/// iPhone Duo: which side bar bar starts compressed when the strip cannot hold both whole.
|
|
12
|
+
public enum DuoBarCompressionBehavior {
|
|
13
|
+
case toolbar, tabBar
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
enum DuoBar {
|
|
17
|
+
case toolbar, tabBar
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
struct DuoBarCompression {
|
|
21
|
+
var toolbarCompressed = false
|
|
22
|
+
var tabBarCompressed = false
|
|
23
|
+
/// The open rail needs the toolbar button slot too: only Back stays.
|
|
24
|
+
var toolbarHidden = false
|
|
25
|
+
var expandToolbar: () -> Void = {}
|
|
26
|
+
var expandTabBar: () -> Void = {}
|
|
27
|
+
/// A rail opened from the compressed state closes once a tab is picked.
|
|
28
|
+
var onTabSelected: () -> Void = {}
|
|
29
|
+
/// Side bar toolkits report their full height so the tab bar knows what is left.
|
|
30
|
+
var setToolbarHeight: (CGFloat) -> Void = { _ in }
|
|
31
|
+
|
|
32
|
+
/// Space kept between the top and the bottom groups (HIG Vertical controls).
|
|
33
|
+
static let groupGap = Spacing.M
|
|
34
|
+
/// Back, then the right toolkit: help + home capsule.
|
|
35
|
+
static let defaultToolbarHeight = DuoMetrics.controlSize + Spacing.M + 2 * DuoMetrics.itemPitch
|
|
36
|
+
/// Back, then the single button the toolbar folds into.
|
|
37
|
+
private static let compressedToolbarHeight = 2 * DuoMetrics.controlSize + Spacing.M
|
|
38
|
+
/// Back alone, while a rail opened from the compressed state takes the toolbar slot.
|
|
39
|
+
private static let backOnlyHeight = DuoMetrics.controlSize
|
|
40
|
+
|
|
41
|
+
/// - both fit: both stay whole
|
|
42
|
+
/// - one fits whole beside the other compressed: one is compressed, tapping it swaps them
|
|
43
|
+
/// - otherwise both rest compressed; tapping one opens it for a moment (the rail closes
|
|
44
|
+
/// again once a tab is picked)
|
|
45
|
+
static func resolve(
|
|
46
|
+
layout: ScreenLayout,
|
|
47
|
+
tabCount: Int,
|
|
48
|
+
hasQr: Bool,
|
|
49
|
+
toolbarHeight: CGFloat,
|
|
50
|
+
expanded: Binding<DuoBar>,
|
|
51
|
+
opened: Binding<DuoBar?>,
|
|
52
|
+
setToolbarHeight: @escaping (CGFloat) -> Void
|
|
53
|
+
) -> DuoBarCompression {
|
|
54
|
+
guard let sideBar = layout.sideBar else { return DuoBarCompression() }
|
|
55
|
+
let room = layout.height - sideBar.bottom - sideBar.top - groupGap
|
|
56
|
+
let tabBar = duoRailHeight(itemCount: tabCount, hasQr: hasQr)
|
|
57
|
+
let compressedTabBar = duoRailHeight(itemCount: 1, hasQr: hasQr, compressed: true)
|
|
58
|
+
func fits(_ toolbar: CGFloat, _ tabs: CGFloat) -> Bool { toolbar + tabs <= room }
|
|
59
|
+
|
|
60
|
+
if fits(toolbarHeight, tabBar) {
|
|
61
|
+
return DuoBarCompression(setToolbarHeight: setToolbarHeight)
|
|
62
|
+
}
|
|
63
|
+
if fits(compressedToolbarHeight, tabBar) && fits(toolbarHeight, compressedTabBar) {
|
|
64
|
+
return DuoBarCompression(
|
|
65
|
+
toolbarCompressed: expanded.wrappedValue == .tabBar,
|
|
66
|
+
tabBarCompressed: expanded.wrappedValue == .toolbar,
|
|
67
|
+
expandToolbar: { expanded.wrappedValue = .toolbar },
|
|
68
|
+
expandTabBar: { expanded.wrappedValue = .tabBar },
|
|
69
|
+
setToolbarHeight: setToolbarHeight
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let expandToolbar = { opened.wrappedValue = .toolbar }
|
|
74
|
+
let expandTabBar = { opened.wrappedValue = .tabBar }
|
|
75
|
+
if opened.wrappedValue == .tabBar && fits(backOnlyHeight, tabBar) {
|
|
76
|
+
return DuoBarCompression(
|
|
77
|
+
toolbarCompressed: true,
|
|
78
|
+
tabBarCompressed: false,
|
|
79
|
+
toolbarHidden: true,
|
|
80
|
+
expandToolbar: expandToolbar,
|
|
81
|
+
expandTabBar: expandTabBar,
|
|
82
|
+
onTabSelected: { opened.wrappedValue = nil },
|
|
83
|
+
setToolbarHeight: setToolbarHeight
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
return DuoBarCompression(
|
|
87
|
+
toolbarCompressed: opened.wrappedValue != .toolbar,
|
|
88
|
+
tabBarCompressed: true,
|
|
89
|
+
expandToolbar: expandToolbar,
|
|
90
|
+
expandTabBar: expandTabBar,
|
|
91
|
+
setToolbarHeight: setToolbarHeight
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private struct DuoBarCompressionKey: EnvironmentKey {
|
|
97
|
+
static let defaultValue = DuoBarCompression()
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
extension EnvironmentValues {
|
|
101
|
+
var duoBarCompression: DuoBarCompression {
|
|
102
|
+
get { self[DuoBarCompressionKey.self] }
|
|
103
|
+
set { self[DuoBarCompressionKey.self] = newValue }
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -28,7 +28,7 @@ private let tabSpring = Animation.interpolatingSpring(mass: 0.8, stiffness: 220,
|
|
|
28
28
|
struct DuoTabBarInsets: Equatable {
|
|
29
29
|
/// Floating bar overlays the content: scroll content, footer and FAB keep clear of it.
|
|
30
30
|
var overlay: CGFloat = 0
|
|
31
|
-
/// Rail sits at the bottom of the side strip: the header side bar stays above
|
|
31
|
+
/// Rail sits at the bottom of the side strip: the header side bar stays this far above the window bottom.
|
|
32
32
|
var sideBarReserve: CGFloat = 0
|
|
33
33
|
}
|
|
34
34
|
|
|
@@ -43,16 +43,21 @@ extension EnvironmentValues {
|
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
/// Rail plus the QR button under it; compressed, the rail is a single button.
|
|
47
|
+
func duoRailHeight(itemCount: Int, hasQr: Bool, compressed: Bool = false) -> CGFloat {
|
|
48
|
+
let count = CGFloat(itemCount)
|
|
49
|
+
let rail = compressed ? itemSize : railPadding * 2 + count * itemSize + max(count - 1, 0) * railGap
|
|
50
|
+
return rail + (hasQr ? Spacing.M + itemSize : 0)
|
|
51
|
+
}
|
|
52
|
+
|
|
46
53
|
extension ScreenLayout {
|
|
47
|
-
func duoTabBarInsets(itemCount: Int, hasFloatingButton: Bool) -> DuoTabBarInsets {
|
|
54
|
+
func duoTabBarInsets(itemCount: Int, hasFloatingButton: Bool, tabBarCompressed: Bool = false) -> DuoTabBarInsets {
|
|
48
55
|
guard isIPhoneDuo else { return DuoTabBarInsets() }
|
|
49
|
-
guard sideBar
|
|
56
|
+
guard let sideBar else {
|
|
50
57
|
return DuoTabBarInsets(overlay: duoFloatingTabBarHeight + Spacing.M)
|
|
51
58
|
}
|
|
52
|
-
let
|
|
53
|
-
|
|
54
|
-
let qr = hasFloatingButton ? Spacing.M + itemSize : 0
|
|
55
|
-
return DuoTabBarInsets(sideBarReserve: rail + qr + Spacing.XL)
|
|
59
|
+
let rail = duoRailHeight(itemCount: itemCount, hasQr: hasFloatingButton, compressed: tabBarCompressed)
|
|
60
|
+
return DuoTabBarInsets(sideBarReserve: sideBar.bottom + rail + DuoBarCompression.groupGap)
|
|
56
61
|
}
|
|
57
62
|
}
|
|
58
63
|
|
|
@@ -95,13 +100,14 @@ struct DuoTabBar: View {
|
|
|
95
100
|
let floatingButton: BottomTabFloatingButton?
|
|
96
101
|
let selectedIndex: Int
|
|
97
102
|
let onTabSelected: (Int) -> Void
|
|
103
|
+
var compression = DuoBarCompression()
|
|
98
104
|
|
|
99
105
|
@Environment(\.screenLayout) private var layout
|
|
100
106
|
|
|
101
107
|
var body: some View {
|
|
102
108
|
if let sideBar = layout.sideBar {
|
|
103
109
|
DuoRailTabBar(sideBar: sideBar, items: items, floatingButton: floatingButton,
|
|
104
|
-
selectedIndex: selectedIndex, onTabSelected: onTabSelected)
|
|
110
|
+
selectedIndex: selectedIndex, onTabSelected: onTabSelected, compression: compression)
|
|
105
111
|
} else {
|
|
106
112
|
DuoFloatingTabBar(items: items, floatingButton: floatingButton,
|
|
107
113
|
selectedIndex: selectedIndex, onTabSelected: onTabSelected)
|
|
@@ -110,12 +116,15 @@ struct DuoTabBar: View {
|
|
|
110
116
|
}
|
|
111
117
|
|
|
112
118
|
/// Vertical bar anchored to the bottom of the side bar, QR button below it (Figma MT1-C/E).
|
|
119
|
+
/// When the side bar is short it can shrink to one button showing the current tab
|
|
120
|
+
/// (HIG "Tab bar compressed"); tapping it expands the rail and compresses the toolbar.
|
|
113
121
|
private struct DuoRailTabBar: View {
|
|
114
122
|
let sideBar: SideBarLayout
|
|
115
123
|
let items: [BottomTabItem]
|
|
116
124
|
let floatingButton: BottomTabFloatingButton?
|
|
117
125
|
let selectedIndex: Int
|
|
118
126
|
let onTabSelected: (Int) -> Void
|
|
127
|
+
let compression: DuoBarCompression
|
|
119
128
|
|
|
120
129
|
@Environment(\.appTheme) private var theme
|
|
121
130
|
|
|
@@ -123,30 +132,19 @@ private struct DuoRailTabBar: View {
|
|
|
123
132
|
|
|
124
133
|
var body: some View {
|
|
125
134
|
VStack(spacing: Spacing.M) {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
+ CGFloat(selectedIndex) * (itemSize + railGap))
|
|
132
|
-
.animation(tabSpring, value: selectedIndex)
|
|
133
|
-
|
|
134
|
-
VStack(spacing: railGap) {
|
|
135
|
-
ForEach(items.indices, id: \.self) { index in
|
|
136
|
-
SwiftUI.Button { onTabSelected(index) } label: {
|
|
137
|
-
DuoTabIcon(item: items[index], selected: index == selectedIndex)
|
|
138
|
-
.frame(width: itemSize, height: itemSize)
|
|
139
|
-
.contentShape(Rectangle())
|
|
140
|
-
}
|
|
141
|
-
.buttonStyle(.plain)
|
|
142
|
-
.accessibilityLabel(items[index].label)
|
|
143
|
-
}
|
|
135
|
+
if compression.tabBarCompressed, items.indices.contains(selectedIndex) {
|
|
136
|
+
SwiftUI.Button(action: compression.expandTabBar) {
|
|
137
|
+
DuoTabIcon(item: items[selectedIndex], selected: true)
|
|
138
|
+
.frame(width: itemSize, height: itemSize)
|
|
139
|
+
.contentShape(Circle())
|
|
144
140
|
}
|
|
145
|
-
.
|
|
141
|
+
.buttonStyle(.plain)
|
|
142
|
+
.modifier(DuoBarMaterial(shape: Circle()))
|
|
143
|
+
.accessibilityLabel(items[selectedIndex].label)
|
|
144
|
+
.accessibilityIdentifier("bottom_tab_bar")
|
|
145
|
+
} else {
|
|
146
|
+
rail
|
|
146
147
|
}
|
|
147
|
-
.frame(width: itemSize)
|
|
148
|
-
.modifier(DuoBarMaterial(shape: RoundedRectangle(cornerRadius: Radius.XL)))
|
|
149
|
-
.accessibilityIdentifier("bottom_tab_bar")
|
|
150
148
|
|
|
151
149
|
if let floatingButton {
|
|
152
150
|
DuoQrButton(data: floatingButton, size: itemSize)
|
|
@@ -154,10 +152,41 @@ private struct DuoRailTabBar: View {
|
|
|
154
152
|
}
|
|
155
153
|
.padding(isRight ? .leading : .trailing, Spacing.M)
|
|
156
154
|
.frame(width: sideBar.width, alignment: isRight ? .leading : .trailing)
|
|
157
|
-
.padding(.bottom,
|
|
155
|
+
.padding(.bottom, sideBar.bottom)
|
|
158
156
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: isRight ? .bottomTrailing : .bottomLeading)
|
|
159
157
|
.ignoresSafeArea()
|
|
160
158
|
}
|
|
159
|
+
|
|
160
|
+
/// Glass column of tab items with the sliding selection bubble.
|
|
161
|
+
private var rail: some View {
|
|
162
|
+
ZStack(alignment: .top) {
|
|
163
|
+
Capsule()
|
|
164
|
+
.fill(theme.colors.background.selected)
|
|
165
|
+
.frame(width: railBubbleWidth, height: railBubbleHeight)
|
|
166
|
+
.offset(y: railPadding - (railBubbleHeight - itemSize) / 2
|
|
167
|
+
+ CGFloat(selectedIndex) * (itemSize + railGap))
|
|
168
|
+
.animation(tabSpring, value: selectedIndex)
|
|
169
|
+
|
|
170
|
+
VStack(spacing: railGap) {
|
|
171
|
+
ForEach(items.indices, id: \.self) { index in
|
|
172
|
+
SwiftUI.Button {
|
|
173
|
+
onTabSelected(index)
|
|
174
|
+
compression.onTabSelected()
|
|
175
|
+
} label: {
|
|
176
|
+
DuoTabIcon(item: items[index], selected: index == selectedIndex)
|
|
177
|
+
.frame(width: itemSize, height: itemSize)
|
|
178
|
+
.contentShape(Rectangle())
|
|
179
|
+
}
|
|
180
|
+
.buttonStyle(.plain)
|
|
181
|
+
.accessibilityLabel(items[index].label)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
.padding(.vertical, railPadding)
|
|
185
|
+
}
|
|
186
|
+
.frame(width: itemSize)
|
|
187
|
+
.modifier(DuoBarMaterial(shape: RoundedRectangle(cornerRadius: Radius.XL)))
|
|
188
|
+
.accessibilityIdentifier("bottom_tab_bar")
|
|
189
|
+
}
|
|
161
190
|
}
|
|
162
191
|
|
|
163
192
|
/// Floating labelled bar for the unfolded portrait display, QR in the middle cell (Figma MT1-M).
|
|
@@ -17,39 +17,38 @@ struct DuoScrollGradient: View {
|
|
|
17
17
|
let scrollOffset: CGFloat
|
|
18
18
|
let headerHeight: CGFloat
|
|
19
19
|
let extended: Bool
|
|
20
|
+
/// Quick return header: the gradient leaves with the content instead of fading.
|
|
21
|
+
var followScroll = false
|
|
20
22
|
|
|
21
23
|
@Environment(\.appTheme) private var theme
|
|
22
|
-
@Environment(\.screenLayout) private var layout
|
|
23
24
|
|
|
24
25
|
private var scroll: CGFloat { max(scrollOffset, 0) }
|
|
25
26
|
|
|
26
|
-
private var gradient: LinearGradient {
|
|
27
|
-
LinearGradient(
|
|
28
|
-
colors: [theme.colors.gradient, theme.colors.gradient.opacity(0)],
|
|
29
|
-
startPoint: .top,
|
|
30
|
-
endPoint: .bottom
|
|
31
|
-
)
|
|
32
|
-
}
|
|
33
|
-
|
|
34
27
|
var body: some View {
|
|
35
28
|
Group {
|
|
36
|
-
if
|
|
37
|
-
// extended squeezes the whole fade into its box and fades out
|
|
38
|
-
let progress = min(scroll / extendedCollapseDistance, 1)
|
|
29
|
+
if followScroll {
|
|
39
30
|
gradient
|
|
40
|
-
.frame(height: headerHeight +
|
|
41
|
-
.
|
|
31
|
+
.frame(height: headerHeight + (extended ? extendedGradientExtra : 0))
|
|
32
|
+
.offset(y: -scroll)
|
|
42
33
|
} else {
|
|
43
|
-
//
|
|
34
|
+
// fades out on scroll as the extended header does off Duo; extended also collapses
|
|
35
|
+
let progress = min(scroll / extendedCollapseDistance, 1)
|
|
44
36
|
gradient
|
|
45
|
-
.frame(height:
|
|
46
|
-
.
|
|
47
|
-
.clipped()
|
|
48
|
-
.offset(y: -scroll)
|
|
37
|
+
.frame(height: headerHeight + (extended ? extendedGradientExtra * (1 - progress) : 0))
|
|
38
|
+
.opacity(1 - min(scroll / extendedGradientExtra, 1))
|
|
49
39
|
}
|
|
50
40
|
}
|
|
51
41
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
|
52
42
|
.ignoresSafeArea()
|
|
53
43
|
.allowsHitTesting(false)
|
|
54
44
|
}
|
|
45
|
+
|
|
46
|
+
/// The fade spans the gradient box.
|
|
47
|
+
private var gradient: LinearGradient {
|
|
48
|
+
LinearGradient(
|
|
49
|
+
colors: [theme.colors.gradient, theme.colors.gradient.opacity(0)],
|
|
50
|
+
startPoint: .top,
|
|
51
|
+
endPoint: .bottom
|
|
52
|
+
)
|
|
53
|
+
}
|
|
55
54
|
}
|
|
@@ -113,8 +113,9 @@ public struct NavigationHeader: View {
|
|
|
113
113
|
}
|
|
114
114
|
.frame(height: layout.headerBarHeight)
|
|
115
115
|
.overlay(alignment: .bottom) { VerticalShadow(opacity: opacity) }
|
|
116
|
-
//
|
|
117
|
-
|
|
116
|
+
// the row starts headerStatusBarHeight below the window top: 0 with the status bar off
|
|
117
|
+
// the top edge, lower on the iPhone Duo outer display to center on the camera
|
|
118
|
+
.padding(.top, layout.headerStatusBarHeight - layout.insets.top)
|
|
118
119
|
}
|
|
119
120
|
}
|
|
120
121
|
|
|
@@ -31,6 +31,7 @@ struct ToolkitNavigationHeaderRight: View {
|
|
|
31
31
|
@State private var isFavorite: Bool = false
|
|
32
32
|
@State private var isLoading: Bool = false
|
|
33
33
|
@Environment(\.applicationEnvironment) private var environment
|
|
34
|
+
@Environment(\.duoBarCompression) private var compression
|
|
34
35
|
|
|
35
36
|
private func apiValue(_ value: Any?) -> Any {
|
|
36
37
|
value ?? NSNull()
|
|
@@ -169,7 +170,38 @@ struct ToolkitNavigationHeaderRight: View {
|
|
|
169
170
|
} ?? false
|
|
170
171
|
}
|
|
171
172
|
|
|
173
|
+
/// Back slot, the optional shortcut, then the help/home capsule.
|
|
174
|
+
private var fullToolbarHeight: CGFloat {
|
|
175
|
+
DuoMetrics.controlSize + Spacing.M
|
|
176
|
+
+ (shouldShowShortcut ? DuoMetrics.controlSize + Spacing.M : 0)
|
|
177
|
+
+ (showHelpCenter ? 2 * DuoMetrics.itemPitch : DuoMetrics.controlSize)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
@ViewBuilder
|
|
172
181
|
private var verticalBody: some View {
|
|
182
|
+
Group {
|
|
183
|
+
if compression.toolbarHidden {
|
|
184
|
+
// an open tab rail takes this slot for a moment
|
|
185
|
+
EmptyView()
|
|
186
|
+
} else if compression.toolbarCompressed {
|
|
187
|
+
// HIG "Toolbar compressed": tools fold into one button, distinct from the useMore icon
|
|
188
|
+
SideBarButton(
|
|
189
|
+
icon: "navigation_menu",
|
|
190
|
+
tintColor: tintColor,
|
|
191
|
+
showBadge: showBadge,
|
|
192
|
+
accessibilityId: "btn_navigation_toolbar",
|
|
193
|
+
action: compression.expandToolbar
|
|
194
|
+
)
|
|
195
|
+
} else {
|
|
196
|
+
toolbarColumn
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
// full height, so the tab bar compression decision matches this screen's toolkit
|
|
200
|
+
.onAppear { compression.setToolbarHeight(fullToolbarHeight) }
|
|
201
|
+
.onChange(of: fullToolbarHeight) { compression.setToolbarHeight($0) }
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private var toolbarColumn: some View {
|
|
173
205
|
let navButtonConfig = getNavigationButtonConfig()
|
|
174
206
|
return VStack(spacing: Spacing.M) {
|
|
175
207
|
if shouldShowShortcut {
|
|
@@ -24,7 +24,7 @@ struct HeaderSideBar: View {
|
|
|
24
24
|
let goBack: (() -> Void)?
|
|
25
25
|
let toolkit: (data: HeaderRightData?, tintColor: Color?)?
|
|
26
26
|
let headerColor: HeaderColor
|
|
27
|
-
/// What footer / tab bar / home indicator take from the bottom; the column stays above it.
|
|
27
|
+
/// What footer / tab bar / home indicator take from the bottom, gap included; the column stays above it.
|
|
28
28
|
let bottomInset: CGFloat
|
|
29
29
|
|
|
30
30
|
@Environment(\.screenLayout) private var layout
|
|
@@ -33,7 +33,7 @@ struct HeaderSideBar: View {
|
|
|
33
33
|
private var isRight: Bool { sideBar.edge == .right }
|
|
34
34
|
|
|
35
35
|
private var top: CGFloat {
|
|
36
|
-
let maxY = layout.height - bottomInset -
|
|
36
|
+
let maxY = layout.height - bottomInset - columnHeight
|
|
37
37
|
return max(min(sideBar.top, maxY), 0)
|
|
38
38
|
}
|
|
39
39
|
|
|
@@ -102,6 +102,9 @@ public struct StackScreen<Content: View>: View {
|
|
|
102
102
|
@State private var keyboardHeight: CGFloat = 0
|
|
103
103
|
@State private var headerRightWidth: CGFloat = 0
|
|
104
104
|
@State private var isBottomTabRoot: Bool = false
|
|
105
|
+
@State private var headerHide: CGFloat = 0
|
|
106
|
+
@State private var lastScrollOffset: CGFloat = 0
|
|
107
|
+
@State private var headerSettle: Task<Void, Never>?
|
|
105
108
|
|
|
106
109
|
@Environment(\.applicationEnvironment) private var appEnv
|
|
107
110
|
@Environment(\.screenLayout) private var layout
|
|
@@ -140,11 +143,44 @@ public struct StackScreen<Content: View>: View {
|
|
|
140
143
|
&& inputSearchProps == nil && !headerTransparent && !fullScreenContent
|
|
141
144
|
}
|
|
142
145
|
|
|
143
|
-
///
|
|
146
|
+
/// iPhone Duo: the header leaves with the content and slides back in on a reverse scroll
|
|
147
|
+
/// (RN DuoQuickReturnHeader). The side bar is drawn apart from the header, so it stays put.
|
|
148
|
+
private var useQuickReturn: Bool {
|
|
149
|
+
layout.sideBar != nil && scrollable && (useDuoGlass || animatedHeader != nil)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private func updateHeaderHide(_ offset: CGFloat) {
|
|
153
|
+
let delta = offset - lastScrollOffset
|
|
154
|
+
lastScrollOffset = offset
|
|
155
|
+
guard useQuickReturn, offset > 0 else {
|
|
156
|
+
if headerHide != 0 { headerHide = 0 }
|
|
157
|
+
return
|
|
158
|
+
}
|
|
159
|
+
headerHide = min(max(headerHide + delta, 0), layout.headerHeight)
|
|
160
|
+
settleHeaderWhenScrollStops()
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/// A half-shown header settles fully in or out. SwiftUI before iOS 18 reports no scroll
|
|
164
|
+
/// phase, so the scroll counts as ended once the offset stops changing.
|
|
165
|
+
private func settleHeaderWhenScrollStops() {
|
|
166
|
+
headerSettle?.cancel()
|
|
167
|
+
headerSettle = Task { @MainActor in
|
|
168
|
+
try? await Task.sleep(nanoseconds: 150_000_000)
|
|
169
|
+
guard !Task.isCancelled else { return }
|
|
170
|
+
let height = layout.headerHeight
|
|
171
|
+
// within the first header height the header still sits on the content
|
|
172
|
+
guard scrollOffset >= height, headerHide > 0, headerHide < height else { return }
|
|
173
|
+
withAnimation(.easeOut(duration: 0.2)) {
|
|
174
|
+
headerHide = headerHide > height / 2 ? height : 0
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/// What the footer, tab bar or home indicator takes from the bottom of the window, gap included.
|
|
144
180
|
private var bottomBarsHeight: CGFloat {
|
|
145
|
-
if footer != nil { return footerHeight + NavigationFooter.safeAreaBottom }
|
|
146
|
-
if duoTabBarInsets.sideBarReserve > 0 { return duoTabBarInsets.sideBarReserve
|
|
147
|
-
return NavigationFooter.navigationBarInset
|
|
181
|
+
if footer != nil { return footerHeight + NavigationFooter.safeAreaBottom + Spacing.XL }
|
|
182
|
+
if duoTabBarInsets.sideBarReserve > 0 { return duoTabBarInsets.sideBarReserve }
|
|
183
|
+
return NavigationFooter.navigationBarInset + Spacing.XL
|
|
148
184
|
}
|
|
149
185
|
|
|
150
186
|
/// Keyboard frames are in screen coordinates; UIScreen stays on the outer display on iPhone Duo.
|
|
@@ -234,6 +270,7 @@ public struct StackScreen<Content: View>: View {
|
|
|
234
270
|
}
|
|
235
271
|
}
|
|
236
272
|
.ignoresSafeArea(.keyboard, edges: .bottom)
|
|
273
|
+
.onChange(of: scrollOffset) { updateHeaderHide($0) }
|
|
237
274
|
.onPreferenceChange(HeaderRightWidthKey.self) { width in
|
|
238
275
|
if headerRightWidth != width { headerRightWidth = width }
|
|
239
276
|
}
|
|
@@ -319,6 +356,7 @@ public struct StackScreen<Content: View>: View {
|
|
|
319
356
|
}
|
|
320
357
|
.edgesIgnoringSafeArea([.top, .horizontal])
|
|
321
358
|
)
|
|
359
|
+
.offset(y: -headerHide)
|
|
322
360
|
.zIndex(100)
|
|
323
361
|
}
|
|
324
362
|
} else if headerTransparent && fullScreenContent {
|
|
@@ -342,7 +380,8 @@ public struct StackScreen<Content: View>: View {
|
|
|
342
380
|
DuoScrollGradient(
|
|
343
381
|
scrollOffset: scrollOffset,
|
|
344
382
|
headerHeight: layout.headerHeight,
|
|
345
|
-
extended: headerType == .extended
|
|
383
|
+
extended: headerType == .extended,
|
|
384
|
+
followScroll: useQuickReturn
|
|
346
385
|
)
|
|
347
386
|
|
|
348
387
|
StackScreenContent(
|
|
@@ -360,6 +399,7 @@ public struct StackScreen<Content: View>: View {
|
|
|
360
399
|
.modifier(DuoBarMaterial(shape: Rectangle(), visible: scrollOffset > 0))
|
|
361
400
|
.edgesIgnoringSafeArea([.top, .horizontal])
|
|
362
401
|
)
|
|
402
|
+
.offset(y: -headerHide)
|
|
363
403
|
.zIndex(100)
|
|
364
404
|
}
|
|
365
405
|
} else {
|
|
@@ -526,7 +566,7 @@ private struct SearchAnimatedHeader: View {
|
|
|
526
566
|
let scroll = max(scrollOffset, 0)
|
|
527
567
|
|
|
528
568
|
// Insets are measured from the safe-area top; collapsed, the search is centered in the bar row.
|
|
529
|
-
let rowTop =
|
|
569
|
+
let rowTop = layout.headerStatusBarHeight - layout.insets.top
|
|
530
570
|
let minTop = rowTop + (layout.headerBarHeight - headerHeight) / 2
|
|
531
571
|
let maxTop = layout.headerHeight - layout.insets.top
|
|
532
572
|
let minSide = Spacing.M
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
//
|
|
5
5
|
// Mirrors RN `app/screens/IPhoneDuo/DuoTabUsage`: glass rail in the Duo side bar, floating
|
|
6
6
|
// glass bar when unfolded portrait. `useShortcut` gives the tallest side bar header, for the
|
|
7
|
-
// overlap review
|
|
7
|
+
// overlap review; `compressTabBar` folds the tab bar instead of the toolbar when the side bar
|
|
8
|
+
// is short (HIG "Tab bar compressed").
|
|
8
9
|
//
|
|
9
10
|
|
|
10
11
|
import MoMoUIKits
|
|
@@ -15,10 +16,11 @@ private let history = (1...18).map { "Giao dịch #\($0)" }
|
|
|
15
16
|
|
|
16
17
|
struct DuoTabDemo: View {
|
|
17
18
|
let useShortcut: Bool
|
|
19
|
+
var compressTabBar = false
|
|
18
20
|
|
|
19
21
|
var body: some View {
|
|
20
22
|
if #available(iOS 16.0, *) {
|
|
21
|
-
DuoTabDemoContent(useShortcut: useShortcut)
|
|
23
|
+
DuoTabDemoContent(useShortcut: useShortcut, compressTabBar: compressTabBar)
|
|
22
24
|
} else {
|
|
23
25
|
Text("Requires iOS 16+").padding()
|
|
24
26
|
}
|
|
@@ -28,6 +30,7 @@ struct DuoTabDemo: View {
|
|
|
28
30
|
@available(iOS 16.0, *)
|
|
29
31
|
private struct DuoTabDemoContent: View {
|
|
30
32
|
let useShortcut: Bool
|
|
33
|
+
let compressTabBar: Bool
|
|
31
34
|
|
|
32
35
|
var body: some View {
|
|
33
36
|
BottomTab(
|
|
@@ -37,7 +40,8 @@ private struct DuoTabDemoContent: View {
|
|
|
37
40
|
tab("DuoHistory", label: "Lịch sử GD", icon: "time_clock_reset", title: "Lịch sử giao dịch", badge: "3"),
|
|
38
41
|
tab("DuoMe", label: "Tôi", icon: "basic_person", title: "Tôi"),
|
|
39
42
|
],
|
|
40
|
-
floatingButton: BottomTabFloatingButton(icon: "navigation_full_screen", label: "Quét mọi QR", onPress: {})
|
|
43
|
+
floatingButton: BottomTabFloatingButton(icon: "navigation_full_screen", label: "Quét mọi QR", onPress: {}),
|
|
44
|
+
compressionBehavior: compressTabBar ? .tabBar : .toolbar
|
|
41
45
|
)
|
|
42
46
|
}
|
|
43
47
|
|
|
@@ -53,6 +53,9 @@ private struct IPhoneDuoDemoContent: View {
|
|
|
53
53
|
demoButton("Glass tab bar + useShortcut") {
|
|
54
54
|
navigator.push({ DuoTabDemo(useShortcut: true) }, options: tabOptions)
|
|
55
55
|
}
|
|
56
|
+
demoButton("Glass tab bar + compress tab bar") {
|
|
57
|
+
navigator.push({ DuoTabDemo(useShortcut: true, compressTabBar: true) }, options: tabOptions)
|
|
58
|
+
}
|
|
56
59
|
demoButton("Bottom sheet: iPhone Duo safe area") {
|
|
57
60
|
navigator.showBottomSheet({ BottomSheetSafeArea() }, isSurface: true, header: .title("Safe area"))
|
|
58
61
|
}
|