@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 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`. When the status bar is off the top edge (iPhone landscape, iPhone Duo) `headerStatusBarHeight` is 0 so the title stays centered.
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. The column starts at 120pt when the inner display is landscape (height ≥ 600), else 170pt, or below the header when the status bar is on top; UIKit's `statusBarFrame` is useless on Duo. Unlike RN it is clamped 24pt above footer / tab bar / rail.
70
- - Tab bar on Duo is `bottomtab/DuoTabBar.kt`: a rail in the side strip (anchored 24pt from the 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.
71
- - 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 scrolls away / collapses.
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(size: CGSize, insets: EdgeInsets, isPhone: Bool = isPhoneIdiom) -> ScreenLayout {
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
- let (headerHeight, headerStatusBarHeight) = headerGeometry(topInset: insets.top, isPhone: isPhone)
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
- let isInnerLandscape = width > height && height >= DuoMetrics.innerMinHeight
103
- top = isInnerLandscape ? DuoMetrics.firstItemTopLandscape : DuoMetrics.firstItemTop
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: insets.trailing >= insets.leading ? .right : .left,
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
- public init(items: [BottomTabItem], floatingButton: BottomTabFloatingButton? = nil) {
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 duoInsets: DuoTabBarInsets {
34
- layout.duoTabBarInsets(itemCount: items.count, hasFloatingButton: floatingButton != nil)
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 it.
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 != nil else {
56
+ guard let sideBar else {
50
57
  return DuoTabBarInsets(overlay: duoFloatingTabBarHeight + Spacing.M)
51
58
  }
52
- let count = CGFloat(itemCount)
53
- let rail = railPadding * 2 + count * itemSize + max(count - 1, 0) * railGap
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
- ZStack(alignment: .top) {
127
- Capsule()
128
- .fill(theme.colors.background.selected)
129
- .frame(width: railBubbleWidth, height: railBubbleHeight)
130
- .offset(y: railPadding - (railBubbleHeight - itemSize) / 2
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
- .padding(.vertical, railPadding)
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, Spacing.XL)
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 extended {
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 + extendedGradientExtra * (1 - progress))
41
- .opacity(1 - min(scroll / extendedGradientExtra, 1))
31
+ .frame(height: headerHeight + (extended ? extendedGradientExtra : 0))
32
+ .offset(y: -scroll)
42
33
  } else {
43
- // default keeps the off-Duo 375:154 shape, the header clips it, and scrolls away
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: layout.width * 154 / 375)
46
- .frame(height: headerHeight, alignment: .top)
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
- // status bar off the top edge: the row spans the whole header from the window top
117
- .padding(.top, min(layout.headerStatusBarHeight - layout.insets.top, 0))
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 - Spacing.XL - columnHeight
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
- /// Height the footer, tab bar or home indicator takes from the bottom of the window.
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 - Spacing.XL }
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 = min(layout.headerStatusBarHeight - layout.insets.top, 0)
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@momo-kits/native-kits",
3
- "version": "0.167.1-duo.7",
3
+ "version": "0.167.1-duo.8",
4
4
  "private": false,
5
5
  "dependeNavigationncies": {},
6
6
  "devDependencies": {},