@momo-kits/native-kits 0.167.1-duo.2-debug → 0.167.1-duo.3-debug

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/.claude/scheduled_tasks.lock +1 -0
  2. package/.claude/settings.local.json +185 -0
  3. package/CLAUDE.md +8 -3
  4. package/compose/build.gradle.kts +15 -1
  5. package/compose/build.gradle.kts.backup +14 -0
  6. package/compose/compose.podspec +1 -1
  7. package/compose/src/androidMain/kotlin/vn/momo/kits/platform/Platform.android.kt +2 -0
  8. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PopupNotify.kt +52 -25
  9. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PopupPromotion.kt +12 -2
  10. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/ScreenLayout.kt +45 -15
  11. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/BottomSheet.kt +43 -4
  12. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/ModalScreen.kt +8 -0
  13. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/StackScreen.kt +34 -8
  14. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTab.kt +81 -62
  15. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/DuoTabBar.kt +281 -0
  16. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/Header.kt +31 -4
  17. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderBackground.kt +27 -2
  18. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderRight.kt +17 -10
  19. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/Platform.kt +3 -0
  20. package/compose/src/iosMain/kotlin/vn/momo/kits/platform/Platform.ios.kt +4 -0
  21. package/gradle.properties +1 -1
  22. package/ios/Application/HeaderRight.swift +25 -12
  23. package/ios/Application/Layout/ScreenLayout.swift +196 -0
  24. package/ios/Application/Navigation/BottomTab/BottomTab.swift +30 -9
  25. package/ios/Application/Navigation/BottomTab/BottomTabBar.swift +5 -2
  26. package/ios/Application/Navigation/BottomTab/DuoTabBar.swift +275 -0
  27. package/ios/Application/Navigation/HeaderUser.swift +4 -1
  28. package/ios/Application/Navigation/NavigationContainer.swift +3 -0
  29. package/ios/Application/Navigation/Overplay/BottomSheet.swift +41 -4
  30. package/ios/Application/Navigation/Overplay/ModalScreen.swift +5 -1
  31. package/ios/Application/Navigation/component/DuoScrollGradient.swift +55 -0
  32. package/ios/Application/Navigation/component/Header.swift +9 -3
  33. package/ios/Application/Navigation/component/HeaderBackground.swift +3 -2
  34. package/ios/Application/Navigation/component/NavigationFooter.swift +1 -1
  35. package/ios/Application/Navigation/component/NavigationHeaderRight.swift +61 -9
  36. package/ios/Application/Navigation/component/SideBar.swift +155 -0
  37. package/ios/Application/StackScreen.swift +135 -18
  38. package/ios/Popup/PopupDisplay.swift +84 -68
  39. package/ios/Popup/PopupLayout.swift +45 -0
  40. package/ios/Popup/PopupNotify.swift +43 -32
  41. package/ios/Popup/PopupPromotion.swift +17 -5
  42. package/ios/native-kits.podspec +1 -1
  43. package/ios-demo/MoMoUIKitsDemo.podspec +1 -1
  44. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/DuoTabDemo.swift +76 -0
  45. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/Home.swift +1 -0
  46. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/IPhoneDuoDemo.swift +170 -0
  47. package/local.properties +8 -0
  48. package/package.json +1 -1
@@ -16,6 +16,7 @@ struct ModalScreen<Content: View>: View {
16
16
 
17
17
  @EnvironmentObject private var navigator: Navigator
18
18
  @Environment(\.applicationEnvironment) private var appEnv
19
+ @Environment(\.screenLayout) private var layout
19
20
 
20
21
  @State private var scrimOpacity: CGFloat = 0
21
22
  @State private var contentScale: CGFloat = 0.8
@@ -35,8 +36,11 @@ struct ModalScreen<Content: View>: View {
35
36
  content()
36
37
  .scaleEffect(contentScale)
37
38
  .opacity(contentOpacity)
39
+ // iPhone Duo: center below the side status bar and above the home indicator
40
+ .padding(.top, layout.isDuoDevice ? layout.duoTopInset : 0)
41
+ .padding(.bottom, layout.isDuoDevice ? layout.insets.bottom : 0)
38
42
  }
39
- // Center on the full screen like Compose; landscape safe areas are asymmetric (camera on one side).
43
+ // Full screen like RN's Modal, so asymmetric landscape insets don't shift the content sideways.
40
44
  .frame(maxWidth: .infinity, maxHeight: .infinity)
41
45
  .ignoresSafeArea(.container)
42
46
  .onAppear {
@@ -0,0 +1,55 @@
1
+ //
2
+ // DuoScrollGradient.swift
3
+ // MoMoUIKits
4
+ //
5
+ // Port of RN `DuoScrollGradient` (Components/DuoGlassBackground.tsx): with the iPhone Duo
6
+ // glass header the content scrolls under the bar, so the header gradient is drawn under it.
7
+ //
8
+
9
+ import SwiftUI
10
+
11
+ // extended headers run their gradient this far below the header, as off Duo
12
+ private let extendedGradientExtra: CGFloat = 52
13
+ // scroll distance over which the extended gradient collapses to the header
14
+ private let extendedCollapseDistance: CGFloat = 100
15
+
16
+ struct DuoScrollGradient: View {
17
+ let scrollOffset: CGFloat
18
+ let headerHeight: CGFloat
19
+ let extended: Bool
20
+
21
+ @Environment(\.appTheme) private var theme
22
+ @Environment(\.screenLayout) private var layout
23
+
24
+ private var scroll: CGFloat { max(scrollOffset, 0) }
25
+
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
+ var body: some View {
35
+ Group {
36
+ if extended {
37
+ // extended squeezes the whole fade into its box and fades out
38
+ let progress = min(scroll / extendedCollapseDistance, 1)
39
+ gradient
40
+ .frame(height: headerHeight + extendedGradientExtra * (1 - progress))
41
+ .opacity(1 - min(scroll / extendedGradientExtra, 1))
42
+ } else {
43
+ // default keeps the off-Duo 375:154 shape, the header clips it, and scrolls away
44
+ gradient
45
+ .frame(height: layout.width * 154 / 375)
46
+ .frame(height: headerHeight, alignment: .top)
47
+ .clipped()
48
+ .offset(y: -scroll)
49
+ }
50
+ }
51
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
52
+ .ignoresSafeArea()
53
+ .allowsHitTesting(false)
54
+ }
55
+ }
@@ -60,7 +60,11 @@ public struct NavigationHeader: View {
60
60
  self.titleOpacity = titleOpacity
61
61
  }
62
62
 
63
+ @Environment(\.screenLayout) private var layout
64
+
63
65
  private var isAnimated: Bool { animatedHeader != nil }
66
+ /// iPhone Duo side bar holds back and the toolkit; a custom header right stays in the bar.
67
+ private var useSideBar: Bool { layout.sideBar != nil }
64
68
  private var isSurface: Bool { animatedHeader?.isSurface ?? true }
65
69
 
66
70
  private var headerColor: HeaderColor {
@@ -79,7 +83,7 @@ public struct NavigationHeader: View {
79
83
  if headerType != .none {
80
84
  ZStack {
81
85
  HStack(spacing: 0) {
82
- if let goBack {
86
+ if let goBack, !useSideBar {
83
87
  BackButton(color: headerColor, onBack: goBack)
84
88
  Spacer().frame(width: Spacing.S)
85
89
  }
@@ -91,7 +95,7 @@ public struct NavigationHeader: View {
91
95
  .frame(maxWidth: .infinity, alignment: .leading)
92
96
  }
93
97
 
94
- if let headerRight {
98
+ if let headerRight, !(useSideBar && toolkitHeaderRight(headerRight) != nil) {
95
99
  AnyView(headerRight())
96
100
  .background(
97
101
  GeometryReader { geo in
@@ -107,8 +111,10 @@ public struct NavigationHeader: View {
107
111
  .padding(.horizontal, Spacing.M)
108
112
  }
109
113
  }
110
- .frame(height: HEADER_HEIGHT)
114
+ .frame(height: layout.headerBarHeight)
111
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))
112
118
  }
113
119
  }
114
120
 
@@ -43,10 +43,11 @@ public struct NavigationHeaderBackground: View {
43
43
  )
44
44
  }
45
45
 
46
+ @Environment(\.screenLayout) private var layout
47
+
46
48
  public var body: some View {
47
- let statusBarHeight = safeAreaTopInset()
48
49
  let opacity = max(0, 1 - scrollState / CGFloat(HEADER_HEIGHT))
49
- let minHeight = fullScreenContent ? 0 : statusBarHeight + HEADER_HEIGHT
50
+ let minHeight = fullScreenContent ? 0 : layout.headerHeight
50
51
  let maxHeight: CGFloat = 154
51
52
  let backgroundColor = headerTransparent ? Color.clear : Colors.black01
52
53
  let topColor = opacity == 0 ? backgroundColor : Color(hex: 0xFDCADE)
@@ -69,7 +69,7 @@ public struct NavigationFooter: View {
69
69
  .padding(.bottom, Self.bottomPadding)
70
70
  // Compose paints the surface all the way to the bottom of the screen; the
71
71
  // footer's own frame stops at the safe area, so only the fill bleeds down.
72
- .background(theme.colors.background.surface.ignoresSafeArea(.container, edges: .bottom))
72
+ .background(theme.colors.background.surface.ignoresSafeArea(.container, edges: [.bottom, .horizontal]))
73
73
  .overlay(alignment: .top) { shadow }
74
74
  .offset(y: Self.verticalOffset(keyboardSize: keyboardSize))
75
75
  }
@@ -26,6 +26,8 @@ public struct NavigationHeaderRight: View {
26
26
  struct ToolkitNavigationHeaderRight: View {
27
27
  var headerRight: HeaderRightData?
28
28
  var tintColor: Color?
29
+ /// iPhone Duo side bar column instead of the bar row.
30
+ var vertical: Bool = false
29
31
  @State private var isFavorite: Bool = false
30
32
  @State private var isLoading: Bool = false
31
33
  @Environment(\.applicationEnvironment) private var environment
@@ -142,15 +144,68 @@ struct ToolkitNavigationHeaderRight: View {
142
144
  return NavigationButtonConfig(icon: icon, onPress: onClickHandler)
143
145
  }
144
146
 
147
+ private var showHelpCenter: Bool {
148
+ environment.applicationContext != nil && showsHelpCenter(environment.applicationContext)
149
+ }
150
+
151
+ private var closeIcon: String { headerRight?.closeIcon ?? "16_basic_home" }
152
+
145
153
  var body: some View {
154
+ Group {
155
+ if vertical {
156
+ verticalBody
157
+ } else {
158
+ horizontalBody
159
+ }
160
+ }
161
+ .onAppear {
162
+ loadFavoriteState()
163
+ }
164
+ }
165
+
166
+ private var showBadge: Bool {
167
+ headerRight?.tools.contains { group in
168
+ group.items.contains { $0.showBadge }
169
+ } ?? false
170
+ }
171
+
172
+ private var verticalBody: some View {
173
+ let navButtonConfig = getNavigationButtonConfig()
174
+ return VStack(spacing: Spacing.M) {
175
+ if shouldShowShortcut {
176
+ SideBarButton(
177
+ icon: navButtonConfig.icon,
178
+ tintColor: tintColor,
179
+ showBadge: showBadge,
180
+ disabled: isLoading,
181
+ action: navButtonConfig.onPress
182
+ )
183
+ }
184
+ SideBarGroup(tintColor: tintColor, itemCount: showHelpCenter ? 2 : 1) {
185
+ if showHelpCenter {
186
+ SideBarGroupItem(
187
+ icon: "help_center",
188
+ tintColor: tintColor,
189
+ accessibilityId: "btn_navigation_help_center",
190
+ action: onPressHelpCenter
191
+ )
192
+ }
193
+ SideBarGroupItem(
194
+ icon: closeIcon,
195
+ tintColor: tintColor,
196
+ accessibilityId: "btn_navigation_close",
197
+ action: onPressClose
198
+ )
199
+ }
200
+ }
201
+ }
202
+
203
+ private var horizontalBody: some View {
146
204
  let backgroundButtonColor: Color = tintColor == Colors.black01 ? Colors.black20.opacity(0.6) : Colors.black01.opacity(0.6)
147
205
  let borderColor: Color = tintColor == Colors.black01 ? Colors.black01.opacity(0.2) : Colors.black20.opacity(0.2)
148
206
  let navButtonConfig = getNavigationButtonConfig()
149
- let showBadge = headerRight?.tools.contains { group in
150
- group.items.contains { $0.showBadge }
151
- } ?? false
152
207
 
153
- HStack(alignment: .center, spacing: 0) {
208
+ return HStack(alignment: .center, spacing: 0) {
154
209
  if shouldShowShortcut {
155
210
  NavigationButton(
156
211
  disabled: isLoading,
@@ -162,7 +217,7 @@ struct ToolkitNavigationHeaderRight: View {
162
217
  }
163
218
 
164
219
  HStack(alignment: .center, spacing: 0) {
165
- if environment.applicationContext != nil {
220
+ if showHelpCenter {
166
221
  Icon(source: "help_center", size: 20, color: tintColor ?? Colors.black17)
167
222
  .padding(4)
168
223
  .onTapGesture {
@@ -174,7 +229,7 @@ struct ToolkitNavigationHeaderRight: View {
174
229
  .frame(width: 0.5, height: 12)
175
230
  }
176
231
 
177
- Icon(source: "16_basic_home", size: 20, color: tintColor ?? Colors.black17)
232
+ Icon(source: closeIcon, size: 20, color: tintColor ?? Colors.black17)
178
233
  .padding(4)
179
234
  .onTapGesture {
180
235
  onPressClose()
@@ -189,8 +244,5 @@ struct ToolkitNavigationHeaderRight: View {
189
244
  )
190
245
  .padding(.leading, Spacing.S)
191
246
  }
192
- .onAppear {
193
- loadFavoriteState()
194
- }
195
247
  }
196
248
  }
@@ -0,0 +1,155 @@
1
+ //
2
+ // SideBar.swift
3
+ // MoMoUIKits
4
+ //
5
+ // iPhone Duo: back and toolkit leave the header bar for a column in the side safe area,
6
+ // as the system bars do. Port of RN `SideBarButton.tsx` + `useSideBarItemStyle`.
7
+ //
8
+
9
+ import SwiftUI
10
+
11
+ private let sideBarIconSize: CGFloat = 24
12
+
13
+ /// Toolkit data when `headerRight` renders the kit toolkit; nil for a custom header right.
14
+ func toolkitHeaderRight(_ headerRight: (() -> any View)?) -> (data: HeaderRightData?, tintColor: Color?)? {
15
+ guard let view = headerRight?() else { return nil }
16
+ if let toolkit = view as? HeaderRight { return (toolkit.headerRight, toolkit.tintColor) }
17
+ if let toolkit = view as? NavigationHeaderRight { return (toolkit.headerRight, toolkit.tintColor) }
18
+ return nil
19
+ }
20
+
21
+ /// The back button and toolkit column, placed in window coordinates over the side strip.
22
+ struct HeaderSideBar: View {
23
+ let sideBar: SideBarLayout
24
+ let goBack: (() -> Void)?
25
+ let toolkit: (data: HeaderRightData?, tintColor: Color?)?
26
+ let headerColor: HeaderColor
27
+ /// What footer / tab bar / home indicator take from the bottom; the column stays above it.
28
+ let bottomInset: CGFloat
29
+
30
+ @Environment(\.screenLayout) private var layout
31
+ @State private var columnHeight: CGFloat = 0
32
+
33
+ private var isRight: Bool { sideBar.edge == .right }
34
+
35
+ private var top: CGFloat {
36
+ let maxY = layout.height - bottomInset - Spacing.XL - columnHeight
37
+ return max(min(sideBar.top, maxY), 0)
38
+ }
39
+
40
+ var body: some View {
41
+ VStack(alignment: isRight ? .leading : .trailing, spacing: Spacing.M) {
42
+ if let goBack {
43
+ SideBarButton(
44
+ icon: "arrow-back",
45
+ tintColor: headerColor.tintIconColor,
46
+ accessibilityId: "btn_navigation_back",
47
+ action: goBack
48
+ )
49
+ } else {
50
+ // keep the slot so the toolkit does not jump between root and pushed screens
51
+ Color.clear.frame(width: DuoMetrics.controlSize, height: DuoMetrics.controlSize)
52
+ }
53
+ if let toolkit {
54
+ ToolkitNavigationHeaderRight(
55
+ headerRight: toolkit.data,
56
+ tintColor: toolkit.tintColor ?? headerColor.tintIconColor,
57
+ vertical: true
58
+ )
59
+ }
60
+ }
61
+ .padding(isRight ? .leading : .trailing, Spacing.M)
62
+ .frame(width: sideBar.width, alignment: isRight ? .leading : .trailing)
63
+ .background(
64
+ GeometryReader { proxy in
65
+ Color.clear
66
+ .onAppear { columnHeight = proxy.size.height }
67
+ .onChange(of: proxy.size.height) { columnHeight = $0 }
68
+ }
69
+ )
70
+ .padding(.top, top)
71
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: isRight ? .topTrailing : .topLeading)
72
+ .ignoresSafeArea()
73
+ }
74
+ }
75
+
76
+ /// Dark glass under light icons (banner headers), light glass otherwise.
77
+ private struct SideBarGlass<S: Shape>: ViewModifier {
78
+ let tintColor: Color?
79
+ let shape: S
80
+
81
+ private var isDark: Bool { tintColor == Colors.black01 || tintColor == .white }
82
+
83
+ func body(content: Content) -> some View {
84
+ if isDark {
85
+ content
86
+ .background(shape.fill(Colors.black20.opacity(0.5)))
87
+ .overlay(shape.stroke(Colors.black01.opacity(0.2), lineWidth: 0.5))
88
+ } else {
89
+ content
90
+ .background(
91
+ shape.fill(Colors.black01.opacity(0.85))
92
+ .shadow(color: Color.black.opacity(0.12), radius: 4, x: 0, y: 2)
93
+ )
94
+ .overlay(shape.stroke(Colors.black20.opacity(0.08), lineWidth: 0.5))
95
+ }
96
+ }
97
+ }
98
+
99
+ /// 48pt glass circle for a single side bar control.
100
+ struct SideBarButton: View {
101
+ let icon: String
102
+ var tintColor: Color?
103
+ var showBadge: Bool = false
104
+ var disabled: Bool = false
105
+ var accessibilityId: String?
106
+ let action: () -> Void
107
+
108
+ var body: some View {
109
+ SwiftUI.Button(action: { if !disabled { action() } }) {
110
+ Icon(source: icon, size: sideBarIconSize, color: tintColor ?? Colors.black17)
111
+ .frame(width: DuoMetrics.controlSize, height: DuoMetrics.controlSize)
112
+ .modifier(SideBarGlass(tintColor: tintColor, shape: Circle()))
113
+ .overlay(alignment: .topTrailing) {
114
+ if showBadge {
115
+ BadgeDot(size: .small).offset(x: -Spacing.XXS, y: Spacing.XXS)
116
+ }
117
+ }
118
+ }
119
+ .buttonStyle(.plain)
120
+ .accessibilityIdentifier(accessibilityId ?? "")
121
+ }
122
+ }
123
+
124
+ /// 48pt wide glass capsule grouping related controls, one item per 50pt.
125
+ struct SideBarGroup<Content: View>: View {
126
+ var tintColor: Color?
127
+ let itemCount: Int
128
+ @ViewBuilder let content: () -> Content
129
+
130
+ var body: some View {
131
+ VStack(spacing: 0) { content() }
132
+ .frame(
133
+ width: DuoMetrics.controlSize,
134
+ height: itemCount > 1 ? DuoMetrics.itemPitch * CGFloat(itemCount) : DuoMetrics.controlSize
135
+ )
136
+ .modifier(SideBarGlass(tintColor: tintColor, shape: RoundedRectangle(cornerRadius: Radius.XL)))
137
+ }
138
+ }
139
+
140
+ struct SideBarGroupItem: View {
141
+ let icon: String
142
+ var tintColor: Color?
143
+ let accessibilityId: String
144
+ let action: () -> Void
145
+
146
+ var body: some View {
147
+ SwiftUI.Button(action: action) {
148
+ Icon(source: icon, size: sideBarIconSize, color: tintColor ?? Colors.black17)
149
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
150
+ .contentShape(Rectangle())
151
+ }
152
+ .buttonStyle(.plain)
153
+ .accessibilityIdentifier(accessibilityId)
154
+ }
155
+ }
@@ -104,6 +104,9 @@ public struct StackScreen<Content: View>: View {
104
104
  @State private var isBottomTabRoot: Bool = false
105
105
 
106
106
  @Environment(\.applicationEnvironment) private var appEnv
107
+ @Environment(\.screenLayout) private var layout
108
+ @Environment(\.duoTabBarInsets) private var duoTabBarInsets
109
+ @State private var footerHeight: CGFloat = 0
107
110
 
108
111
  // Auto-tracking state (mirrors Compose's `remember { ScreenTrackingState() }` +
109
112
  // the once-computed push/back `action`).
@@ -125,6 +128,30 @@ public struct StackScreen<Content: View>: View {
125
128
 
126
129
  private static var headerHeight: CGFloat { 52 }
127
130
 
131
+ private var toolkit: (data: HeaderRightData?, tintColor: Color?)? { toolkitHeaderRight(headerRight) }
132
+
133
+ private var showsSideBar: Bool {
134
+ layout.sideBar != nil && headerType != .none && !isBottomTabRoot && (goBack != nil || toolkit != nil)
135
+ }
136
+
137
+ /// iPhone Duo: content scrolls under a glass header instead of starting below it.
138
+ private var useDuoGlass: Bool {
139
+ layout.sideBar != nil && headerType != .none && animatedHeader == nil
140
+ && inputSearchProps == nil && !headerTransparent && !fullScreenContent
141
+ }
142
+
143
+ /// Height the footer, tab bar or home indicator takes from the bottom of the window.
144
+ 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
148
+ }
149
+
150
+ /// Keyboard frames are in screen coordinates; UIScreen stays on the outer display on iPhone Duo.
151
+ private var keyboardReferenceHeight: CGFloat {
152
+ layout.isIPhoneDuo ? layout.height : UIScreen.main.bounds.height
153
+ }
154
+
128
155
  /// Extra top space reserved inside the content so the animated search bar has
129
156
  /// room below the header at scroll 0; it shrinks to 0 as the bar slides up.
130
157
  private var animatedSearchInset: CGFloat {
@@ -163,9 +190,18 @@ public struct StackScreen<Content: View>: View {
163
190
  hasFooter: footer != nil,
164
191
  scrollOffset: scrollOffset
165
192
  )
193
+ .padding(.bottom, footer == nil ? duoTabBarInsets.overlay : 0)
166
194
  }
167
195
 
168
196
  NavigationFooter(footerComponent: footer, keyboardSize: keyboardSize)
197
+ .padding(.bottom, duoTabBarInsets.overlay)
198
+ .background(
199
+ GeometryReader { proxy in
200
+ Color.clear
201
+ .onAppear { footerHeight = proxy.size.height }
202
+ .onChange(of: proxy.size.height) { footerHeight = $0 }
203
+ }
204
+ )
169
205
  }
170
206
 
171
207
  // Animated search bar overlay (mirrors Compose's SearchAnimated layer).
@@ -176,7 +212,24 @@ public struct StackScreen<Content: View>: View {
176
212
  hiddenBack: !isBack,
177
213
  hasHeaderRight: headerRight != nil,
178
214
  headerRightWidth: headerRightWidth,
179
- headerHeight: Self.headerHeight
215
+ headerHeight: Self.headerHeight,
216
+ useSideBar: layout.sideBar != nil,
217
+ hasToolkit: toolkit != nil
218
+ )
219
+ }
220
+
221
+ if showsSideBar, let sideBar = layout.sideBar {
222
+ HeaderSideBar(
223
+ sideBar: sideBar,
224
+ goBack: goBack,
225
+ toolkit: toolkit,
226
+ headerColor: getHeaderColor(
227
+ isAnimated: animatedHeader != nil,
228
+ isSurface: animatedHeader?.isSurface ?? true,
229
+ opacity: scrollOpacity,
230
+ tintColor: tintColor
231
+ ),
232
+ bottomInset: bottomBarsHeight
180
233
  )
181
234
  }
182
235
  }
@@ -191,7 +244,7 @@ public struct StackScreen<Content: View>: View {
191
244
  guard useAvoidKeyboard,
192
245
  let endFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return }
193
246
  withAnimation(.easeOut(duration: 0.25)) {
194
- keyboardHeight = UIScreen.main.bounds.height - endFrame.origin.y
247
+ keyboardHeight = keyboardReferenceHeight - endFrame.origin.y
195
248
  }
196
249
  }
197
250
  // Mirrors Compose's `hideKeyboardOnTap()`: a simultaneous gesture so
@@ -246,15 +299,25 @@ public struct StackScreen<Content: View>: View {
246
299
  scrollOffset: $scrollOffset,
247
300
  scrollToTopCallback: scrollToTopCallback,
248
301
  animatedSearchInset: animatedSearchInset,
302
+ bottomInset: duoTabBarInsets.overlay,
249
303
  content: { animatedHeroBody(animatedHeader) }
250
304
  )
251
305
  .edgesIgnoringSafeArea(.all)
252
306
 
253
307
  header
254
308
  .background(
255
- Colors.black01
256
- .opacity(scrollOpacity)
257
- .edgesIgnoringSafeArea(.top)
309
+ Group {
310
+ if layout.sideBar != nil {
311
+ // glass shows once the banner has scrolled under the header
312
+ Color.clear.modifier(DuoBarMaterial(
313
+ shape: Rectangle(),
314
+ visible: scrollOffset > heroGlassStart(animatedHeader)
315
+ ))
316
+ } else {
317
+ Colors.black01.opacity(scrollOpacity)
318
+ }
319
+ }
320
+ .edgesIgnoringSafeArea([.top, .horizontal])
258
321
  )
259
322
  .zIndex(100)
260
323
  }
@@ -271,9 +334,34 @@ public struct StackScreen<Content: View>: View {
271
334
  )
272
335
  .zIndex(100)
273
336
 
274
- StackScreenContent(scrollable: scrollable, scrollOffset: $scrollOffset, scrollToTopCallback: scrollToTopCallback, animatedSearchInset: animatedSearchInset, content: content)
337
+ StackScreenContent(scrollable: scrollable, scrollOffset: $scrollOffset, scrollToTopCallback: scrollToTopCallback, animatedSearchInset: animatedSearchInset, bottomInset: duoTabBarInsets.overlay, content: { duoInsetContent })
275
338
  .edgesIgnoringSafeArea(.all)
276
339
  }
340
+ } else if useDuoGlass {
341
+ ZStack(alignment: .top) {
342
+ DuoScrollGradient(
343
+ scrollOffset: scrollOffset,
344
+ headerHeight: layout.headerHeight,
345
+ extended: headerType == .extended
346
+ )
347
+
348
+ StackScreenContent(
349
+ scrollable: scrollable,
350
+ scrollOffset: $scrollOffset,
351
+ scrollToTopCallback: scrollToTopCallback,
352
+ topInset: layout.headerHeight - layout.insets.top,
353
+ bottomInset: duoTabBarInsets.overlay,
354
+ content: content
355
+ )
356
+
357
+ header
358
+ .background(
359
+ Color.clear
360
+ .modifier(DuoBarMaterial(shape: Rectangle(), visible: scrollOffset > 0))
361
+ .edgesIgnoringSafeArea([.top, .horizontal])
362
+ )
363
+ .zIndex(100)
364
+ }
277
365
  } else {
278
366
  header
279
367
  .background(
@@ -283,28 +371,40 @@ public struct StackScreen<Content: View>: View {
283
371
  headerTransparent: headerTransparent,
284
372
  fullScreenContent: fullScreenContent
285
373
  )
286
- .edgesIgnoringSafeArea(.top)
374
+ .edgesIgnoringSafeArea([.top, .horizontal])
287
375
  )
288
376
 
289
- StackScreenContent(scrollable: scrollable, scrollOffset: $scrollOffset, scrollToTopCallback: scrollToTopCallback, animatedSearchInset: animatedSearchInset, content: content)
377
+ StackScreenContent(scrollable: scrollable, scrollOffset: $scrollOffset, scrollToTopCallback: scrollToTopCallback, animatedSearchInset: animatedSearchInset, bottomInset: duoTabBarInsets.overlay, content: content)
290
378
  }
291
379
  }
292
380
 
381
+ /// Edge-to-edge branches lay out outside the safe area; iPhone Duo content still keeps
382
+ /// clear of the side strips, as RN's Screen does.
383
+ private var duoInsetContent: some View {
384
+ content()
385
+ .padding(.leading, layout.insetLeft)
386
+ .padding(.trailing, layout.insetRight)
387
+ }
388
+
389
+ private func heroGlassStart(_ animatedHeader: AnimatedHeader) -> CGFloat {
390
+ max(layout.width / animatedHeader.aspectRatio.value - layout.headerHeight, 0)
391
+ }
392
+
293
393
  /// Hero image + body for an animated header. The image fills the width at its
294
394
  /// aspect-ratio height; the body is offset below the header band so it overlaps
295
395
  /// the lower part of the image (mirrors Compose's image box + padded content).
296
396
  @ViewBuilder
297
397
  private func animatedHeroBody(_ animatedHeader: AnimatedHeader) -> some View {
298
- let width = UIScreen.main.bounds.width
398
+ let width = layout.width
299
399
  let imageHeight = width / animatedHeader.aspectRatio.value
300
- let contentTopInset = safeAreaTopInset() + HEADER_HEIGHT + layoutOffset
400
+ let contentTopInset = layout.headerHeight + layoutOffset
301
401
 
302
402
  ZStack(alignment: .top) {
303
403
  animatedHeader.composable(scrollOffset)
304
404
  .frame(width: width, height: imageHeight)
305
405
  .clipped()
306
406
 
307
- content()
407
+ duoInsetContent
308
408
  .padding(.top, contentTopInset)
309
409
  }
310
410
  .frame(maxWidth: .infinity, alignment: .top)
@@ -343,6 +443,10 @@ private struct StackScreenContent<Content: View>: View {
343
443
  /// Extra top space reserved for the animated search bar (mirrors Compose's
344
444
  /// MainContent animated top spacer). Zero for non-animated screens.
345
445
  var animatedSearchInset: CGFloat = 0
446
+ /// Room for a header the content scrolls under (iPhone Duo glass header).
447
+ var topInset: CGFloat = 0
448
+ /// Room for a bar that overlays the content (iPhone Duo floating tab bar).
449
+ var bottomInset: CGFloat = 0
346
450
  let content: () -> Content
347
451
 
348
452
  private let topAnchorID = "stack_screen_top_anchor"
@@ -369,13 +473,17 @@ private struct StackScreenContent<Content: View>: View {
369
473
  }
370
474
  })
371
475
 
372
- if animatedSearchInset > 0 {
373
- Spacer().frame(height: animatedSearchInset)
476
+ if animatedSearchInset + topInset > 0 {
477
+ Spacer().frame(height: animatedSearchInset + topInset)
374
478
  }
375
479
 
376
480
  VStack {
377
481
  content()
378
482
  }
483
+
484
+ if bottomInset > 0 {
485
+ Spacer().frame(height: bottomInset)
486
+ }
379
487
  }
380
488
  }
381
489
  .coordinateSpace(name: scrollSpace)
@@ -390,6 +498,8 @@ private struct StackScreenContent<Content: View>: View {
390
498
  }
391
499
  } else {
392
500
  content()
501
+ .padding(.top, topInset)
502
+ .padding(.bottom, bottomInset)
393
503
  }
394
504
  }
395
505
  }
@@ -407,16 +517,23 @@ private struct SearchAnimatedHeader: View {
407
517
  let hasHeaderRight: Bool
408
518
  let headerRightWidth: CGFloat
409
519
  let headerHeight: CGFloat
520
+ var useSideBar: Bool = false
521
+ var hasToolkit: Bool = false
522
+
523
+ @Environment(\.screenLayout) private var layout
410
524
 
411
525
  var body: some View {
412
526
  let scroll = max(scrollOffset, 0)
413
527
 
414
- // Insets are measured from the safe-area top (status bar handled by layout).
415
- let minTop: CGFloat = 0
416
- let maxTop = headerHeight
528
+ // 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)
530
+ let minTop = rowTop + (layout.headerBarHeight - headerHeight) / 2
531
+ let maxTop = layout.headerHeight - layout.insets.top
417
532
  let minSide = Spacing.M
418
- let maxStart: CGFloat = hiddenBack ? Spacing.M : 48
419
- let maxEnd = headerRightWidth + (hasHeaderRight ? Spacing.M * 2 : Spacing.M)
533
+ let maxStart: CGFloat = hiddenBack || useSideBar ? Spacing.M : 48
534
+ let maxEnd: CGFloat = useSideBar && hasToolkit
535
+ ? Spacing.M
536
+ : headerRightWidth + (hasHeaderRight ? Spacing.M * 2 : Spacing.M)
420
537
 
421
538
  let top = min(max(maxTop - scroll, minTop), maxTop)
422
539
  let start = min(max(minSide + scroll, minSide), maxStart)