@momo-kits/native-kits 0.167.1-duo.7-debug → 0.167.1-duo.8-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 (25) hide show
  1. package/CLAUDE.md +7 -4
  2. package/compose/build.gradle.kts +1 -1
  3. package/compose/src/androidMain/kotlin/vn/momo/kits/platform/ReservedRegions.android.kt +6 -0
  4. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/ScreenLayout.kt +56 -11
  5. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/StackScreen.kt +55 -3
  6. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTab.kt +14 -5
  7. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/DuoBarCompression.kt +98 -0
  8. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/DuoTabBar.kt +71 -40
  9. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderBackground.kt +11 -27
  10. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderRight.kt +19 -7
  11. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/SideBar.kt +20 -4
  12. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/ReservedRegions.kt +13 -0
  13. package/compose/src/iosMain/kotlin/vn/momo/kits/platform/ReservedRegions.ios.kt +86 -0
  14. package/ios/Application/Layout/ScreenLayout.swift +122 -6
  15. package/ios/Application/Navigation/BottomTab/BottomTab.swift +41 -5
  16. package/ios/Application/Navigation/BottomTab/DuoBarCompression.swift +105 -0
  17. package/ios/Application/Navigation/BottomTab/DuoTabBar.swift +60 -31
  18. package/ios/Application/Navigation/component/DuoScrollGradient.swift +18 -19
  19. package/ios/Application/Navigation/component/Header.swift +3 -2
  20. package/ios/Application/Navigation/component/NavigationHeaderRight.swift +32 -0
  21. package/ios/Application/Navigation/component/SideBar.swift +2 -2
  22. package/ios/Application/StackScreen.swift +46 -6
  23. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/DuoTabDemo.swift +7 -3
  24. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/IPhoneDuoDemo.swift +3 -0
  25. package/package.json +1 -1
@@ -3,6 +3,7 @@ package vn.momo.kits.navigation.component
3
3
  import androidx.compose.animation.core.animateFloatAsState
4
4
  import androidx.compose.foundation.background
5
5
  import androidx.compose.foundation.layout.Box
6
+ import androidx.compose.foundation.layout.fillMaxHeight
6
7
  import androidx.compose.foundation.layout.fillMaxSize
7
8
  import androidx.compose.foundation.layout.fillMaxWidth
8
9
  import androidx.compose.foundation.layout.height
@@ -11,7 +12,6 @@ import androidx.compose.runtime.getValue
11
12
  import androidx.compose.ui.Modifier
12
13
  import androidx.compose.ui.draw.clipToBounds
13
14
  import androidx.compose.ui.graphics.graphicsLayer
14
- import androidx.compose.ui.geometry.Offset
15
15
  import androidx.compose.ui.graphics.Brush
16
16
  import androidx.compose.ui.graphics.Color
17
17
  import androidx.compose.ui.platform.LocalDensity
@@ -32,9 +32,8 @@ enum class AnimatedHeaderRatio(val value: Float){
32
32
 
33
33
  internal val HEADER_BACKGROUND_HEIGHT = 154.dp
34
34
 
35
- // RN DuoScrollGradient: extended gradient runs this far below the header and collapses over this scroll
35
+ // RN DuoScrollGradient: extended gradient runs this far below the header
36
36
  private val DUO_GRADIENT_EXTRA = 52.dp
37
- private val DUO_COLLAPSE_DISTANCE = 100.dp
38
37
 
39
38
  @Suppress("FrequentlyChangingValue")
40
39
  @Composable
@@ -65,37 +64,22 @@ internal fun HeaderBackground() {
65
64
  else -> 0.dp
66
65
  }
67
66
 
68
- // iPhone Duo glass header: default gradient scrolls away, extended collapses to the header and fades
67
+ // iPhone Duo glass header: the gradient leaves with the content, as the quick return header does
69
68
  val useDuoGlass = options.usesDuoGlassHeader(layout)
70
- val scrollPx = scrollState.value.toFloat()
71
- val duoModifier = when {
72
- !useDuoGlass -> Modifier.height(height)
73
- options.headerType is HeaderType.Extended -> {
74
- val collapse = with(density) {
75
- (scrollPx / minOf(DUO_COLLAPSE_DISTANCE, minHeight).toPx()).coerceIn(0f, 1f)
76
- }
77
- val fade = with(density) { (scrollPx / DUO_GRADIENT_EXTRA.toPx()).coerceIn(0f, 1f) }
78
- Modifier
79
- .height(minHeight + DUO_GRADIENT_EXTRA * (1f - collapse))
80
- .graphicsLayer { alpha = 1f - fade }
81
- }
82
- else -> Modifier.height(minHeight).graphicsLayer { translationY = -scrollPx }
69
+ val duoModifier = if (useDuoGlass) {
70
+ val extra = if (options.headerType is HeaderType.Extended) DUO_GRADIENT_EXTRA else 0.dp
71
+ Modifier.height(minHeight + extra).graphicsLayer { translationY = -scrollState.value.toFloat() }
72
+ } else {
73
+ Modifier.height(height)
83
74
  }
84
75
 
85
76
  Box(Modifier.fillMaxWidth().then(duoModifier).clipToBounds().background(backgroundColor)) {
77
+ // on Duo the fade spans the gradient box; a banner image keeps its 375:154 shape, clipped
86
78
  Box(Modifier
87
- .height(maxHeight)
88
79
  .fillMaxWidth()
80
+ .then(if (useDuoGlass && !theme.isHeaderImage()) Modifier.fillMaxHeight() else Modifier.height(maxHeight))
89
81
  .conditional(!theme.isHeaderImage()) {
90
- background(Brush.linearGradient(
91
- colors = listOf(
92
- color,
93
- backgroundColor
94
- ),
95
- start = Offset(0f, 0f),
96
- end = Offset(0f, with(density) { maxHeight.toPx() })
97
- )
98
- )
82
+ background(Brush.verticalGradient(colors = listOf(color, backgroundColor)))
99
83
  }
100
84
  ){
101
85
  if (!theme.isHeaderImage()) return@Box
@@ -26,7 +26,9 @@ import androidx.compose.ui.Alignment
26
26
  import androidx.compose.ui.Modifier
27
27
  import androidx.compose.ui.draw.clip
28
28
  import androidx.compose.ui.graphics.Color
29
+ import androidx.compose.ui.unit.Dp
29
30
  import androidx.compose.ui.unit.dp
31
+ import vn.momo.kits.layout.DuoMetrics
30
32
  import vn.momo.kits.application.LocalContext
31
33
  import vn.momo.kits.application.MiniAppContext
32
34
  import vn.momo.kits.components.BadgeDot
@@ -62,6 +64,21 @@ fun HeaderRight(
62
64
 
63
65
  private const val HELP_CENTER_APP_ID = "vn.momo.helpcenter"
64
66
 
67
+ private fun usesHelpCenter(context: MiniAppContext?): Boolean =
68
+ context != null && context.appId != HELP_CENTER_APP_ID
69
+
70
+ private fun showsShortcut(headerRight: HeaderRight.Toolkit, context: MiniAppContext?): Boolean =
71
+ headerRight.useShortcut && !(headerRight.useMore && context == null)
72
+
73
+ // Height of the vertical toolkit: the optional shortcut, then the help/home capsule.
74
+ @Composable
75
+ internal fun toolkitColumnHeight(headerRight: HeaderRight.Toolkit): Dp {
76
+ val context = LocalContext.current
77
+ val shortcut = if (showsShortcut(headerRight, context)) DuoMetrics.controlSize + Spacing.M else 0.dp
78
+ val groupCount = if (usesHelpCenter(context)) 2 else 1
79
+ return shortcut + if (groupCount > 1) DuoMetrics.itemPitch * groupCount else DuoMetrics.controlSize
80
+ }
81
+
65
82
  @Composable
66
83
  internal fun Toolkit(
67
84
  headerRight: HeaderRight.Toolkit,
@@ -124,7 +141,7 @@ internal fun Toolkit(
124
141
  if (headerRight.useCloseIcon) api?.dismiss { } else api?.dismissAll { }
125
142
  }
126
143
 
127
- val useHelpCenter = context != null && context.appId != HELP_CENTER_APP_ID
144
+ val useHelpCenter = usesHelpCenter(context)
128
145
  val closeIcon = if (headerRight.useCloseIcon) "16_navigation_close_circle" else "16_basic_home"
129
146
 
130
147
  val navButtonConfig = getNavigationButtonConfig(
@@ -137,12 +154,7 @@ internal fun Toolkit(
137
154
  group.items.any { it.showBadge }
138
155
  }
139
156
 
140
- val isShowShortcut by remember(headerRight.useShortcut, headerRight.useMore, context) {
141
- derivedStateOf {
142
- (headerRight.useShortcut == true) &&
143
- !(headerRight.useMore == true && context == null)
144
- }
145
- }
157
+ val isShowShortcut = showsShortcut(headerRight, context)
146
158
 
147
159
  val icon by remember(navButtonConfig.icon, isFavorite) {
148
160
  derivedStateOf {
@@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.width
17
17
  import androidx.compose.foundation.shape.CircleShape
18
18
  import androidx.compose.foundation.shape.RoundedCornerShape
19
19
  import androidx.compose.runtime.Composable
20
+ import androidx.compose.runtime.LaunchedEffect
20
21
  import androidx.compose.ui.AbsoluteAlignment
21
22
  import androidx.compose.ui.Alignment
22
23
  import androidx.compose.ui.Modifier
@@ -41,11 +42,12 @@ import vn.momo.kits.modifier.conditional
41
42
  import vn.momo.kits.modifier.setAutomationId
42
43
  import vn.momo.kits.navigation.LocalNavigator
43
44
  import vn.momo.kits.navigation.LocalOptions
45
+ import vn.momo.kits.navigation.bottomtab.LocalDuoBarCompression
44
46
 
45
47
  private val SIDE_BAR_ICON_SIZE = 24.dp
46
48
 
47
49
  // iPhone Duo: back and toolkit leave the bar for a column in the side safe area, as the system bars do.
48
- // bottomInset is what footer / tab bar / home indicator take from the bottom; the column stays above it.
50
+ // bottomInset is what footer / tab bar / home indicator take from the bottom, gap included; the column stays above it.
49
51
  @Composable
50
52
  internal fun BoxScope.HeaderSideBar(bottomInset: Dp, modifier: Modifier = Modifier) {
51
53
  val options = LocalOptions.current
@@ -56,6 +58,11 @@ internal fun BoxScope.HeaderSideBar(bottomInset: Dp, modifier: Modifier = Modifi
56
58
 
57
59
  val headerColor = getHeaderColor(options.headerType, headerOpacity(), options.tintColor, Colors.black_17)
58
60
  val isRight = sideBar.edge == SideBarEdge.RIGHT
61
+ val compression = LocalDuoBarCompression.current
62
+
63
+ // full height, so the tab bar compression decision matches this screen's toolkit
64
+ val fullHeight = DuoMetrics.controlSize + (toolkit?.let { Spacing.M + toolkitColumnHeight(it) } ?: 0.dp)
65
+ LaunchedEffect(fullHeight, compression.setToolbarHeight) { compression.setToolbarHeight(fullHeight) }
59
66
 
60
67
  Column(
61
68
  modifier = modifier
@@ -63,7 +70,7 @@ internal fun BoxScope.HeaderSideBar(bottomInset: Dp, modifier: Modifier = Modifi
63
70
  .width(sideBar.width)
64
71
  .layout { measurable, constraints ->
65
72
  val placeable = measurable.measure(constraints.copy(minHeight = 0))
66
- val maxY = constraints.maxHeight - (bottomInset + Spacing.XL).roundToPx() - placeable.height
73
+ val maxY = constraints.maxHeight - bottomInset.roundToPx() - placeable.height
67
74
  val y = minOf(sideBar.top.roundToPx(), maxY).coerceAtLeast(0)
68
75
  layout(placeable.width, constraints.constrainHeight(y + placeable.height)) {
69
76
  placeable.place(0, y)
@@ -84,8 +91,17 @@ internal fun BoxScope.HeaderSideBar(bottomInset: Dp, modifier: Modifier = Modifi
84
91
  onClick = { navigator.onBackSafe { } },
85
92
  )
86
93
  }
87
- if (toolkit != null) {
88
- Toolkit(headerRight = toolkit, tintColor = options.tintColor, headerColor = headerColor, vertical = true)
94
+ when {
95
+ toolkit == null || compression.toolbarHidden -> Unit
96
+ // HIG "Toolbar compressed": tools fold into one button, distinct from the useMore icon
97
+ compression.toolbarCompressed -> SideBarButton(
98
+ icon = "navigation_menu",
99
+ headerColor = headerColor,
100
+ showBadge = toolkit.tools.any { group -> group.items.any { it.showBadge } },
101
+ automationId = "btn_navigation_toolbar",
102
+ onClick = compression.expandToolbar,
103
+ )
104
+ else -> Toolkit(headerRight = toolkit, tintColor = options.tintColor, headerColor = headerColor, vertical = true)
89
105
  }
90
106
  }
91
107
  }
@@ -0,0 +1,13 @@
1
+ package vn.momo.kits.platform
2
+
3
+ import androidx.compose.runtime.Composable
4
+ import androidx.compose.runtime.Immutable
5
+ import androidx.compose.ui.unit.Dp
6
+
7
+ @Immutable
8
+ internal data class ReservedRegion(val x: Dp, val y: Dp, val width: Dp, val height: Dp)
9
+
10
+ // Regions the system occludes (iOS 27.1+), in the coordinates of the Compose root view: the
11
+ // iPhone Duo camera, and the status bar cluster as it grows for a call or a Live Activity.
12
+ @Composable
13
+ internal expect fun rememberReservedRegions(): List<ReservedRegion>
@@ -0,0 +1,86 @@
1
+ package vn.momo.kits.platform
2
+
3
+ import androidx.compose.runtime.Composable
4
+ import androidx.compose.runtime.DisposableEffect
5
+ import androidx.compose.runtime.getValue
6
+ import androidx.compose.runtime.mutableStateOf
7
+ import androidx.compose.runtime.remember
8
+ import androidx.compose.runtime.setValue
9
+ import androidx.compose.ui.uikit.LocalUIViewController
10
+ import androidx.compose.ui.unit.dp
11
+ import kotlinx.cinterop.BetaInteropApi
12
+ import kotlinx.cinterop.ExperimentalForeignApi
13
+ import kotlinx.cinterop.readValue
14
+ import kotlinx.cinterop.useContents
15
+ import platform.CoreGraphics.CGRectZero
16
+ import platform.Foundation.NSSelectorFromString
17
+ import platform.Foundation.NSValue
18
+ import platform.Foundation.valueForKey
19
+ import platform.UIKit.CGRectValue
20
+ import platform.UIKit.UIView
21
+ import platform.UIKit.UIViewAutoresizingFlexibleHeight
22
+ import platform.UIKit.UIViewAutoresizingFlexibleWidth
23
+ import platform.darwin.NSObject
24
+ import platform.darwin.NSObjectProtocol
25
+ import platform.Foundation.NSClassFromString
26
+
27
+ // The API is newer than the SDK Kotlin/Native binds, so it is reached through the runtime.
28
+ @OptIn(ExperimentalForeignApi::class)
29
+ private val regionsOfKind = NSSelectorFromString("reservedRegionsOfKind:")
30
+
31
+ @OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
32
+ private fun UIView.occlusionRegions(target: UIView): List<ReservedRegion> {
33
+ if (window == null || !respondsToSelector(regionsOfKind)) return emptyList()
34
+ val kindClass = NSClassFromString("UIViewReservedRegionKind") as? NSObjectProtocol ?: return emptyList()
35
+ val kind = kindClass.performSelector(NSSelectorFromString("occlusionRegionKind")) ?: return emptyList()
36
+ val regions = performSelector(regionsOfKind, withObject = kind) as? List<*> ?: return emptyList()
37
+ return regions.mapNotNull { region ->
38
+ val frame = ((region as? NSObject)?.valueForKey("frame") as? NSValue)?.CGRectValue ?: return@mapNotNull null
39
+ convertRect(frame, toView = target).useContents {
40
+ ReservedRegion(origin.x.dp, origin.y.dp, size.width.dp, size.height.dp)
41
+ }
42
+ }
43
+ }
44
+
45
+ // Invisible full-size probe: reading the regions in layoutSubviews makes UIKit run it again
46
+ // when they change, so nothing polls.
47
+ @OptIn(ExperimentalForeignApi::class)
48
+ private class ReservedRegionsProbe(
49
+ private val onChange: (List<ReservedRegion>) -> Unit,
50
+ ) : UIView(frame = CGRectZero.readValue()) {
51
+ private var last: List<ReservedRegion>? = null
52
+
53
+ init {
54
+ userInteractionEnabled = false
55
+ autoresizingMask = UIViewAutoresizingFlexibleWidth or UIViewAutoresizingFlexibleHeight
56
+ }
57
+
58
+ override fun layoutSubviews() {
59
+ super.layoutSubviews()
60
+ val regions = superview?.let { occlusionRegions(it) } ?: emptyList()
61
+ if (regions != last) {
62
+ last = regions
63
+ onChange(regions)
64
+ }
65
+ }
66
+
67
+ override fun didMoveToWindow() {
68
+ super.didMoveToWindow()
69
+ setNeedsLayout()
70
+ }
71
+ }
72
+
73
+ @OptIn(ExperimentalForeignApi::class)
74
+ @Composable
75
+ internal actual fun rememberReservedRegions(): List<ReservedRegion> {
76
+ if (!isPhoneIdiom()) return emptyList()
77
+ val host = LocalUIViewController.current.view
78
+ var regions by remember { mutableStateOf(emptyList<ReservedRegion>()) }
79
+ DisposableEffect(host) {
80
+ val probe = ReservedRegionsProbe { regions = it }
81
+ probe.setFrame(host.bounds)
82
+ host.insertSubview(probe, atIndex = 0)
83
+ onDispose { probe.removeFromSuperview() }
84
+ }
85
+ return regions
86
+ }
@@ -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
+ }