@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.
- package/CLAUDE.md +7 -4
- package/compose/build.gradle.kts +1 -1
- package/compose/src/androidMain/kotlin/vn/momo/kits/platform/ReservedRegions.android.kt +6 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/layout/ScreenLayout.kt +56 -11
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/StackScreen.kt +55 -3
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTab.kt +14 -5
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/DuoBarCompression.kt +98 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/DuoTabBar.kt +71 -40
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderBackground.kt +11 -27
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderRight.kt +19 -7
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/SideBar.kt +20 -4
- package/compose/src/commonMain/kotlin/vn/momo/kits/platform/ReservedRegions.kt +13 -0
- package/compose/src/iosMain/kotlin/vn/momo/kits/platform/ReservedRegions.ios.kt +86 -0
- package/ios/Application/Layout/ScreenLayout.swift +122 -6
- package/ios/Application/Navigation/BottomTab/BottomTab.swift +41 -5
- package/ios/Application/Navigation/BottomTab/DuoBarCompression.swift +105 -0
- package/ios/Application/Navigation/BottomTab/DuoTabBar.swift +60 -31
- package/ios/Application/Navigation/component/DuoScrollGradient.swift +18 -19
- package/ios/Application/Navigation/component/Header.swift +3 -2
- package/ios/Application/Navigation/component/NavigationHeaderRight.swift +32 -0
- package/ios/Application/Navigation/component/SideBar.swift +2 -2
- package/ios/Application/StackScreen.swift +46 -6
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/DuoTabDemo.swift +7 -3
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/IPhoneDuoDemo.swift +3 -0
- package/package.json +1 -1
|
@@ -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
|
|
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:
|
|
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
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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.
|
|
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
|
|
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
|
|
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 -
|
|
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
|
-
|
|
88
|
-
|
|
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(
|
|
91
|
+
static func make(
|
|
92
|
+
size: CGSize,
|
|
93
|
+
insets: EdgeInsets,
|
|
94
|
+
reservedRegions: [CGRect] = ReservedRegions.current,
|
|
95
|
+
isPhone: Bool = isPhoneIdiom
|
|
96
|
+
) -> ScreenLayout {
|
|
83
97
|
let width = size.width
|
|
84
98
|
let height = size.height
|
|
85
|
-
|
|
99
|
+
var (headerHeight, headerStatusBarHeight) = headerGeometry(topInset: insets.top, isPhone: isPhone)
|
|
86
100
|
let isDuo = isPhone && isIPhoneDuoWindow(width: width, height: height, insets: insets)
|
|
87
101
|
guard isDuo else {
|
|
88
102
|
return ScreenLayout(
|
|
@@ -99,13 +113,32 @@ extension ScreenLayout {
|
|
|
99
113
|
var top = insets.top + HeaderMetrics.barHeight + Spacing.M
|
|
100
114
|
if insets.top < HeaderMetrics.statusBarMinInset {
|
|
101
115
|
// status bar lives in the strip: start under its cluster, as the native bar does
|
|
102
|
-
|
|
103
|
-
|
|
116
|
+
if width <= height {
|
|
117
|
+
top = DuoMetrics.firstItemTop
|
|
118
|
+
} else if height >= DuoMetrics.innerMinHeight {
|
|
119
|
+
top = DuoMetrics.firstItemTopLandscape
|
|
120
|
+
} else {
|
|
121
|
+
// outer display in landscape: no status bar in the strip
|
|
122
|
+
top = DuoMetrics.edgeInset
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if height > width && width < DuoMetrics.innerMinHeight {
|
|
126
|
+
// outer display: the row starts below the top so the title centers on the camera
|
|
127
|
+
headerHeight = DuoMetrics.outerHeaderHeight
|
|
128
|
+
headerStatusBarHeight = DuoMetrics.outerCameraCenterY * 2 - DuoMetrics.outerHeaderHeight
|
|
129
|
+
}
|
|
104
130
|
}
|
|
131
|
+
|
|
132
|
+
let edge: SideBarEdge = insets.trailing >= insets.leading ? .right : .left
|
|
133
|
+
let stripLeft = edge == .right ? width - sideInset : 0
|
|
134
|
+
// clear of the camera and of the status bar cluster, which grows for calls and Live
|
|
135
|
+
// Activities; region frames already include the system's margin
|
|
136
|
+
let occlusion = stripOcclusion(reservedRegions, stripLeft: stripLeft, stripRight: stripLeft + sideInset, windowHeight: height)
|
|
105
137
|
sideBar = SideBarLayout(
|
|
106
|
-
edge:
|
|
138
|
+
edge: edge,
|
|
107
139
|
width: sideInset,
|
|
108
|
-
top: top
|
|
140
|
+
top: max(top, occlusion.top),
|
|
141
|
+
bottom: max(DuoMetrics.edgeInset, occlusion.bottom)
|
|
109
142
|
)
|
|
110
143
|
}
|
|
111
144
|
|
|
@@ -126,6 +159,24 @@ extension ScreenLayout {
|
|
|
126
159
|
)
|
|
127
160
|
}
|
|
128
161
|
|
|
162
|
+
/// How far system regions reach into a side strip from its top and from its bottom end
|
|
163
|
+
/// (e.g. the camera, which sits at either end depending on the rotation).
|
|
164
|
+
private static func stripOcclusion(
|
|
165
|
+
_ regions: [CGRect],
|
|
166
|
+
stripLeft: CGFloat,
|
|
167
|
+
stripRight: CGFloat,
|
|
168
|
+
windowHeight: CGFloat
|
|
169
|
+
) -> (top: CGFloat, bottom: CGFloat) {
|
|
170
|
+
regions.reduce(into: (top: CGFloat(0), bottom: CGFloat(0))) { occlusion, region in
|
|
171
|
+
guard region.minX < stripRight && region.maxX > stripLeft else { return }
|
|
172
|
+
if region.midY < windowHeight / 2 {
|
|
173
|
+
occlusion.top = max(occlusion.top, region.maxY)
|
|
174
|
+
} else {
|
|
175
|
+
occlusion.bottom = max(occlusion.bottom, windowHeight - region.minY)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
129
180
|
// Slab iPhones report equal side insets in landscape; iPhone Duo moves the status bar to one side.
|
|
130
181
|
private static func isIPhoneDuoWindow(width: CGFloat, height: CGFloat, insets: EdgeInsets) -> Bool {
|
|
131
182
|
let shortSide = min(width, height)
|
|
@@ -148,6 +199,70 @@ public func isIPhoneDuoDevice(windowSize: CGSize? = nil) -> Bool {
|
|
|
148
199
|
return max(min(screen.width, screen.height), min(window.width, window.height)) > DuoMetrics.iPhoneMaxShortSide
|
|
149
200
|
}
|
|
150
201
|
|
|
202
|
+
// MARK: - Reserved regions
|
|
203
|
+
|
|
204
|
+
/// System occlusion regions (iOS 27.1+) in window coordinates: the iPhone Duo camera, and the
|
|
205
|
+
/// status bar cluster as it grows for a call or a Live Activity. Empty before iOS 27.1.
|
|
206
|
+
enum ReservedRegions {
|
|
207
|
+
// last report of the probe, so `ScreenLayout.current` includes it
|
|
208
|
+
static var current: [CGRect] = []
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/// Invisible probe: reading the regions in layoutSubviews makes UIKit run it again when they
|
|
212
|
+
/// change, so nothing polls. Port of RN `@momo-kits/reserved-regions`.
|
|
213
|
+
private final class ReservedRegionsProbe: UIView {
|
|
214
|
+
var onChange: (() -> Void)?
|
|
215
|
+
private var last: [CGRect]?
|
|
216
|
+
|
|
217
|
+
override init(frame: CGRect) {
|
|
218
|
+
super.init(frame: frame)
|
|
219
|
+
isUserInteractionEnabled = false
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
required init?(coder: NSCoder) { nil }
|
|
223
|
+
|
|
224
|
+
override func layoutSubviews() {
|
|
225
|
+
super.layoutSubviews()
|
|
226
|
+
let regions = occlusionRegions()
|
|
227
|
+
guard regions != last else { return }
|
|
228
|
+
last = regions
|
|
229
|
+
ReservedRegions.current = regions
|
|
230
|
+
// layout pass: publish on the next turn instead of mutating SwiftUI state mid-update
|
|
231
|
+
DispatchQueue.main.async { [weak self] in self?.onChange?() }
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
override func didMoveToWindow() {
|
|
235
|
+
super.didMoveToWindow()
|
|
236
|
+
setNeedsLayout()
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// The API is newer than the SDKs hosts may build with, so it is reached through the runtime.
|
|
240
|
+
private func occlusionRegions() -> [CGRect] {
|
|
241
|
+
let regionsOfKind = NSSelectorFromString("reservedRegionsOfKind:")
|
|
242
|
+
guard window != nil, responds(to: regionsOfKind),
|
|
243
|
+
let kindType = NSClassFromString("UIViewReservedRegionKind") as? NSObject.Type,
|
|
244
|
+
let kind = (kindType as AnyObject).perform(NSSelectorFromString("occlusionRegionKind"))?.takeUnretainedValue(),
|
|
245
|
+
let regions = perform(regionsOfKind, with: kind)?.takeUnretainedValue() as? [NSObject]
|
|
246
|
+
else { return [] }
|
|
247
|
+
return regions.compactMap { ($0.value(forKey: "frame") as? NSValue)?.cgRectValue }
|
|
248
|
+
.map { convert($0, to: nil) }
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
private struct ReservedRegionsProbeView: UIViewRepresentable {
|
|
253
|
+
let onChange: () -> Void
|
|
254
|
+
|
|
255
|
+
func makeUIView(context: Context) -> ReservedRegionsProbe {
|
|
256
|
+
let probe = ReservedRegionsProbe()
|
|
257
|
+
probe.onChange = onChange
|
|
258
|
+
return probe
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
func updateUIView(_ probe: ReservedRegionsProbe, context: Context) {
|
|
262
|
+
probe.onChange = onChange
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
151
266
|
// MARK: - Environment
|
|
152
267
|
|
|
153
268
|
private struct ScreenLayoutKey: EnvironmentKey {
|
|
@@ -177,6 +292,7 @@ private struct ScreenLayoutReader: ViewModifier {
|
|
|
177
292
|
.onChange(of: proxy.safeAreaInsets) { _ in refresh() }
|
|
178
293
|
}
|
|
179
294
|
)
|
|
295
|
+
.background(ReservedRegionsProbeView { refresh() }.ignoresSafeArea())
|
|
180
296
|
.onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in
|
|
181
297
|
refresh()
|
|
182
298
|
}
|
|
@@ -14,14 +14,25 @@ import SwiftUI
|
|
|
14
14
|
public struct BottomTab: View {
|
|
15
15
|
private let items: [BottomTabItem]
|
|
16
16
|
private let floatingButton: BottomTabFloatingButton?
|
|
17
|
+
private let compressionBehavior: DuoBarCompressionBehavior
|
|
17
18
|
|
|
18
19
|
@EnvironmentObject private var navigator: Navigator
|
|
19
20
|
@Environment(\.screenLayout) private var layout
|
|
20
21
|
@State private var selectedIndex: Int = 0
|
|
22
|
+
@State private var toolbarHeight = DuoBarCompression.defaultToolbarHeight
|
|
23
|
+
@State private var expandedBar: DuoBar?
|
|
24
|
+
@State private var openedBar: DuoBar?
|
|
21
25
|
|
|
22
|
-
|
|
26
|
+
/// `compressionBehavior`: on iPhone Duo, when the side bar can't hold both bars, which one
|
|
27
|
+
/// starts compressed; tapping the compressed one swaps them.
|
|
28
|
+
public init(
|
|
29
|
+
items: [BottomTabItem],
|
|
30
|
+
floatingButton: BottomTabFloatingButton? = nil,
|
|
31
|
+
compressionBehavior: DuoBarCompressionBehavior = .toolbar
|
|
32
|
+
) {
|
|
23
33
|
self.items = items
|
|
24
34
|
self.floatingButton = floatingButton
|
|
35
|
+
self.compressionBehavior = compressionBehavior
|
|
25
36
|
}
|
|
26
37
|
|
|
27
38
|
private var bottomInset: CGFloat { safeAreaBottomInset() }
|
|
@@ -30,11 +41,34 @@ public struct BottomTab: View {
|
|
|
30
41
|
layout.isIPhoneDuo ? layout.insets.bottom : BOTTOM_TAB_BAR_HEIGHT + bottomInset + Spacing.S
|
|
31
42
|
}
|
|
32
43
|
|
|
33
|
-
private var
|
|
34
|
-
|
|
44
|
+
private var compression: DuoBarCompression {
|
|
45
|
+
DuoBarCompression.resolve(
|
|
46
|
+
layout: layout,
|
|
47
|
+
tabCount: items.count,
|
|
48
|
+
hasQr: floatingButton != nil,
|
|
49
|
+
toolbarHeight: toolbarHeight,
|
|
50
|
+
// 'toolbar' compression keeps the tab bar whole until the toolbar is tapped
|
|
51
|
+
expanded: Binding(
|
|
52
|
+
get: { expandedBar ?? (compressionBehavior == .toolbar ? .tabBar : .toolbar) },
|
|
53
|
+
set: { expandedBar = $0 }
|
|
54
|
+
),
|
|
55
|
+
opened: $openedBar,
|
|
56
|
+
setToolbarHeight: { height in
|
|
57
|
+
if toolbarHeight != height { toolbarHeight = height }
|
|
58
|
+
}
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private func duoInsets(_ compression: DuoBarCompression) -> DuoTabBarInsets {
|
|
63
|
+
layout.duoTabBarInsets(
|
|
64
|
+
itemCount: items.count,
|
|
65
|
+
hasFloatingButton: floatingButton != nil,
|
|
66
|
+
tabBarCompressed: compression.tabBarCompressed
|
|
67
|
+
)
|
|
35
68
|
}
|
|
36
69
|
|
|
37
70
|
public var body: some View {
|
|
71
|
+
let compression = compression
|
|
38
72
|
ZStack(alignment: .bottom) {
|
|
39
73
|
// Tab content area, inset above the bar.
|
|
40
74
|
ZStack {
|
|
@@ -46,7 +80,8 @@ public struct BottomTab: View {
|
|
|
46
80
|
}
|
|
47
81
|
}
|
|
48
82
|
.padding(.bottom, reservedBottom)
|
|
49
|
-
.environment(\.duoTabBarInsets, duoInsets)
|
|
83
|
+
.environment(\.duoTabBarInsets, duoInsets(compression))
|
|
84
|
+
.environment(\.duoBarCompression, compression)
|
|
50
85
|
.animation(.easeInOut(duration: 0.2), value: selectedIndex)
|
|
51
86
|
|
|
52
87
|
if layout.isIPhoneDuo {
|
|
@@ -54,7 +89,8 @@ public struct BottomTab: View {
|
|
|
54
89
|
items: items,
|
|
55
90
|
floatingButton: floatingButton,
|
|
56
91
|
selectedIndex: selectedIndex,
|
|
57
|
-
onTabSelected: selectTab
|
|
92
|
+
onTabSelected: selectTab,
|
|
93
|
+
compression: compression
|
|
58
94
|
)
|
|
59
95
|
} else {
|
|
60
96
|
// Tab bar + home-indicator filler.
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
//
|
|
2
|
+
// DuoBarCompression.swift
|
|
3
|
+
// MoMoUIKits
|
|
4
|
+
//
|
|
5
|
+
// Port of RN `BottomTab/DuoBarCompression.tsx` (HIG, Designing for iPhone Duo: Vertical controls):
|
|
6
|
+
// keeps the side bar's toolbar and tab bar clear of each other.
|
|
7
|
+
//
|
|
8
|
+
|
|
9
|
+
import SwiftUI
|
|
10
|
+
|
|
11
|
+
/// iPhone Duo: which side bar bar starts compressed when the strip cannot hold both whole.
|
|
12
|
+
public enum DuoBarCompressionBehavior {
|
|
13
|
+
case toolbar, tabBar
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
enum DuoBar {
|
|
17
|
+
case toolbar, tabBar
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
struct DuoBarCompression {
|
|
21
|
+
var toolbarCompressed = false
|
|
22
|
+
var tabBarCompressed = false
|
|
23
|
+
/// The open rail needs the toolbar button slot too: only Back stays.
|
|
24
|
+
var toolbarHidden = false
|
|
25
|
+
var expandToolbar: () -> Void = {}
|
|
26
|
+
var expandTabBar: () -> Void = {}
|
|
27
|
+
/// A rail opened from the compressed state closes once a tab is picked.
|
|
28
|
+
var onTabSelected: () -> Void = {}
|
|
29
|
+
/// Side bar toolkits report their full height so the tab bar knows what is left.
|
|
30
|
+
var setToolbarHeight: (CGFloat) -> Void = { _ in }
|
|
31
|
+
|
|
32
|
+
/// Space kept between the top and the bottom groups (HIG Vertical controls).
|
|
33
|
+
static let groupGap = Spacing.M
|
|
34
|
+
/// Back, then the right toolkit: help + home capsule.
|
|
35
|
+
static let defaultToolbarHeight = DuoMetrics.controlSize + Spacing.M + 2 * DuoMetrics.itemPitch
|
|
36
|
+
/// Back, then the single button the toolbar folds into.
|
|
37
|
+
private static let compressedToolbarHeight = 2 * DuoMetrics.controlSize + Spacing.M
|
|
38
|
+
/// Back alone, while a rail opened from the compressed state takes the toolbar slot.
|
|
39
|
+
private static let backOnlyHeight = DuoMetrics.controlSize
|
|
40
|
+
|
|
41
|
+
/// - both fit: both stay whole
|
|
42
|
+
/// - one fits whole beside the other compressed: one is compressed, tapping it swaps them
|
|
43
|
+
/// - otherwise both rest compressed; tapping one opens it for a moment (the rail closes
|
|
44
|
+
/// again once a tab is picked)
|
|
45
|
+
static func resolve(
|
|
46
|
+
layout: ScreenLayout,
|
|
47
|
+
tabCount: Int,
|
|
48
|
+
hasQr: Bool,
|
|
49
|
+
toolbarHeight: CGFloat,
|
|
50
|
+
expanded: Binding<DuoBar>,
|
|
51
|
+
opened: Binding<DuoBar?>,
|
|
52
|
+
setToolbarHeight: @escaping (CGFloat) -> Void
|
|
53
|
+
) -> DuoBarCompression {
|
|
54
|
+
guard let sideBar = layout.sideBar else { return DuoBarCompression() }
|
|
55
|
+
let room = layout.height - sideBar.bottom - sideBar.top - groupGap
|
|
56
|
+
let tabBar = duoRailHeight(itemCount: tabCount, hasQr: hasQr)
|
|
57
|
+
let compressedTabBar = duoRailHeight(itemCount: 1, hasQr: hasQr, compressed: true)
|
|
58
|
+
func fits(_ toolbar: CGFloat, _ tabs: CGFloat) -> Bool { toolbar + tabs <= room }
|
|
59
|
+
|
|
60
|
+
if fits(toolbarHeight, tabBar) {
|
|
61
|
+
return DuoBarCompression(setToolbarHeight: setToolbarHeight)
|
|
62
|
+
}
|
|
63
|
+
if fits(compressedToolbarHeight, tabBar) && fits(toolbarHeight, compressedTabBar) {
|
|
64
|
+
return DuoBarCompression(
|
|
65
|
+
toolbarCompressed: expanded.wrappedValue == .tabBar,
|
|
66
|
+
tabBarCompressed: expanded.wrappedValue == .toolbar,
|
|
67
|
+
expandToolbar: { expanded.wrappedValue = .toolbar },
|
|
68
|
+
expandTabBar: { expanded.wrappedValue = .tabBar },
|
|
69
|
+
setToolbarHeight: setToolbarHeight
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let expandToolbar = { opened.wrappedValue = .toolbar }
|
|
74
|
+
let expandTabBar = { opened.wrappedValue = .tabBar }
|
|
75
|
+
if opened.wrappedValue == .tabBar && fits(backOnlyHeight, tabBar) {
|
|
76
|
+
return DuoBarCompression(
|
|
77
|
+
toolbarCompressed: true,
|
|
78
|
+
tabBarCompressed: false,
|
|
79
|
+
toolbarHidden: true,
|
|
80
|
+
expandToolbar: expandToolbar,
|
|
81
|
+
expandTabBar: expandTabBar,
|
|
82
|
+
onTabSelected: { opened.wrappedValue = nil },
|
|
83
|
+
setToolbarHeight: setToolbarHeight
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
return DuoBarCompression(
|
|
87
|
+
toolbarCompressed: opened.wrappedValue != .toolbar,
|
|
88
|
+
tabBarCompressed: true,
|
|
89
|
+
expandToolbar: expandToolbar,
|
|
90
|
+
expandTabBar: expandTabBar,
|
|
91
|
+
setToolbarHeight: setToolbarHeight
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private struct DuoBarCompressionKey: EnvironmentKey {
|
|
97
|
+
static let defaultValue = DuoBarCompression()
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
extension EnvironmentValues {
|
|
101
|
+
var duoBarCompression: DuoBarCompression {
|
|
102
|
+
get { self[DuoBarCompressionKey.self] }
|
|
103
|
+
set { self[DuoBarCompressionKey.self] = newValue }
|
|
104
|
+
}
|
|
105
|
+
}
|