@momo-kits/native-kits 0.162.49-debug → 0.162.50-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 +4 -2
- package/compose/build.gradle.kts +1 -1
- package/compose/compose.podspec +1 -1
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/PopupNotify.kt +25 -16
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/PopupPromotion.kt +3 -3
- package/compose/src/commonMain/kotlin/vn/momo/kits/layout/ScreenLayout.kt +17 -2
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/BottomSheet.kt +209 -45
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/ModalScreen.kt +14 -3
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigator.kt +53 -30
- package/compose/src/iosMain/kotlin/vn/momo/kits/platform/Platform.ios.kt +9 -8
- package/gradle.properties +1 -1
- package/ios/Application/DesignSystemKits.swift +64 -1
- package/ios/Layout/ScreenLayout.swift +23 -3
- package/ios/Navigation/BottomSheet.swift +150 -22
- package/ios/Navigation/Component/SnackBar.swift +4 -0
- package/ios/Navigation/ModalScreen.swift +7 -3
- package/ios/Popup/PopupDisplay.swift +2 -2
- package/ios/Popup/PopupInput.swift +2 -2
- package/ios/Popup/PopupLayout.swift +15 -8
- package/ios/Popup/PopupNotify.swift +2 -2
- package/ios/Typography/FontScale.swift +15 -4
- package/ios/native-kits.podspec +1 -1
- package/ios-demo/MoMoUIKitsDemo.podspec +1 -1
- package/package.json +1 -1
|
@@ -8,8 +8,9 @@ import androidx.compose.foundation.border
|
|
|
8
8
|
import androidx.compose.foundation.clickable
|
|
9
9
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
10
10
|
import androidx.compose.foundation.layout.Box
|
|
11
|
+
import androidx.compose.foundation.layout.absolutePadding
|
|
11
12
|
import androidx.compose.foundation.layout.fillMaxSize
|
|
12
|
-
import androidx.compose.foundation.layout.
|
|
13
|
+
import androidx.compose.foundation.layout.sizeIn
|
|
13
14
|
import androidx.compose.foundation.layout.wrapContentSize
|
|
14
15
|
import androidx.compose.runtime.*
|
|
15
16
|
import androidx.compose.ui.Alignment
|
|
@@ -26,6 +27,8 @@ import vn.momo.kits.modifier.conditional
|
|
|
26
27
|
import vn.momo.kits.modifier.noFeedbackClickable
|
|
27
28
|
import vn.momo.kits.navigation.tracking.ScreenTracker
|
|
28
29
|
import vn.momo.kits.platform.BackHandler
|
|
30
|
+
import vn.momo.kits.layout.duoModalMaxHeight
|
|
31
|
+
import vn.momo.kits.layout.duoModalMaxWidth
|
|
29
32
|
import vn.momo.kits.layout.getScreenLayout
|
|
30
33
|
|
|
31
34
|
@Composable
|
|
@@ -111,9 +114,14 @@ internal fun ModalScreen(
|
|
|
111
114
|
.noFeedbackClickable {
|
|
112
115
|
if(barrierDismissible) closeEvent()
|
|
113
116
|
}
|
|
114
|
-
// center
|
|
117
|
+
// center in the safe area, clear of the side toolbar strip and the home indicator
|
|
115
118
|
.conditional(layout.isIPhoneDuoDevice) {
|
|
116
|
-
|
|
119
|
+
absolutePadding(
|
|
120
|
+
left = layout.insetLeft,
|
|
121
|
+
top = layout.insetTop,
|
|
122
|
+
right = layout.insetRight,
|
|
123
|
+
bottom = layout.insetBottom,
|
|
124
|
+
)
|
|
117
125
|
},
|
|
118
126
|
contentAlignment = Alignment.Center
|
|
119
127
|
) {
|
|
@@ -121,6 +129,9 @@ internal fun ModalScreen(
|
|
|
121
129
|
modifier = Modifier
|
|
122
130
|
.scale(scale.value)
|
|
123
131
|
.alpha(alpha.value)
|
|
132
|
+
.conditional(layout.isIPhoneDuoDevice) {
|
|
133
|
+
sizeIn(maxWidth = layout.duoModalMaxWidth, maxHeight = layout.duoModalMaxHeight)
|
|
134
|
+
}
|
|
124
135
|
.wrapContentSize()
|
|
125
136
|
.conditional(IsShowBaseLineDebug) {
|
|
126
137
|
border(1.dp, Colors.blue_03)
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
package vn.momo.kits.navigation
|
|
2
2
|
|
|
3
3
|
import androidx.compose.foundation.layout.Box
|
|
4
|
+
import androidx.compose.foundation.layout.absolutePadding
|
|
4
5
|
import androidx.compose.foundation.layout.fillMaxSize
|
|
5
6
|
import androidx.compose.runtime.Composable
|
|
6
7
|
import androidx.compose.runtime.MutableState
|
|
8
|
+
import androidx.compose.runtime.key
|
|
7
9
|
import androidx.compose.runtime.mutableStateOf
|
|
8
10
|
import androidx.compose.runtime.staticCompositionLocalOf
|
|
9
11
|
import androidx.compose.ui.Alignment
|
|
@@ -19,6 +21,8 @@ import kotlinx.coroutines.launch
|
|
|
19
21
|
import kotlinx.serialization.Serializable
|
|
20
22
|
import vn.momo.kits.components.PopupAction
|
|
21
23
|
import vn.momo.kits.components.PopupNotify
|
|
24
|
+
import vn.momo.kits.layout.getScreenLayout
|
|
25
|
+
import vn.momo.kits.modifier.conditional
|
|
22
26
|
import vn.momo.kits.navigation.BottomHeader.*
|
|
23
27
|
import vn.momo.kits.navigation.component.SnackBar
|
|
24
28
|
import vn.momo.maxapi.IMaxApi
|
|
@@ -113,9 +117,10 @@ class Navigator(
|
|
|
113
117
|
}
|
|
114
118
|
}
|
|
115
119
|
private suspend fun dismissOverplay(isDelay: Boolean = true) {
|
|
120
|
+
val dismissing = OverplayComponentRegistry.current()
|
|
116
121
|
OverplayComponentRegistry.clear()
|
|
117
122
|
if (isDelay) delay(300L.milliseconds)
|
|
118
|
-
OverplayComponentRegistry.hardClearAfterDismiss()
|
|
123
|
+
OverplayComponentRegistry.hardClearAfterDismiss(dismissing)
|
|
119
124
|
}
|
|
120
125
|
|
|
121
126
|
private suspend fun dismissScreen() {
|
|
@@ -182,9 +187,10 @@ class Navigator(
|
|
|
182
187
|
|
|
183
188
|
fun hideSnackBar() {
|
|
184
189
|
scope.launch {
|
|
190
|
+
val dismissing = OverplayComponentRegistry.current()
|
|
185
191
|
OverplayComponentRegistry.clear()
|
|
186
192
|
delay(250L.milliseconds)
|
|
187
|
-
OverplayComponentRegistry.hardClearAfterDismiss()
|
|
193
|
+
OverplayComponentRegistry.hardClearAfterDismiss(dismissing)
|
|
188
194
|
}
|
|
189
195
|
}
|
|
190
196
|
|
|
@@ -358,42 +364,59 @@ object OverplayComponentRegistry {
|
|
|
358
364
|
requestClose = null
|
|
359
365
|
}
|
|
360
366
|
|
|
367
|
+
internal fun current(): OverplayComponent? = currentOverlayComponent.value
|
|
368
|
+
|
|
369
|
+
// An overlay shown from the dismissed one's onDismiss, before the delayed clear, stays.
|
|
370
|
+
internal fun hardClearAfterDismiss(dismissing: OverplayComponent?) {
|
|
371
|
+
if (currentOverlayComponent.value === dismissing) hardClearAfterDismiss()
|
|
372
|
+
}
|
|
373
|
+
|
|
361
374
|
@Composable
|
|
362
375
|
internal fun OverlayComponent(){
|
|
363
376
|
val overplay = currentOverlayComponent.value ?: return
|
|
364
377
|
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
+
// every registration is its own composition (params are new instances), as SwiftUI's `.id(item.id)`:
|
|
379
|
+
// a sheet shown from another one's close must not inherit its finished animation and effects
|
|
380
|
+
key(overplay) {
|
|
381
|
+
when (val params = overplay.params) {
|
|
382
|
+
is OverplayComponentParams.BottomSheet -> {
|
|
383
|
+
BottomSheet(
|
|
384
|
+
content = overplay.content,
|
|
385
|
+
header = params.bottomSheetHeader ?: Title(),
|
|
386
|
+
isSurface = params.isSurface,
|
|
387
|
+
barrierDismissible = params.barrierDismissible,
|
|
388
|
+
draggable = params.draggable,
|
|
389
|
+
useBottomInset = params.useBottomInset,
|
|
390
|
+
footerComponent = params.footerComponent,
|
|
391
|
+
onDismiss = params.onDismiss
|
|
392
|
+
)
|
|
393
|
+
}
|
|
378
394
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
395
|
+
is OverplayComponentParams.Modal -> {
|
|
396
|
+
ModalScreen(
|
|
397
|
+
content = overplay.content,
|
|
398
|
+
barrierDismissible = params.barrierDismissible,
|
|
399
|
+
onDismiss = params.onDismiss
|
|
400
|
+
)
|
|
401
|
+
}
|
|
386
402
|
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
403
|
+
is OverplayComponentParams.SnackBar -> {
|
|
404
|
+
val layout = getScreenLayout()
|
|
405
|
+
Box(
|
|
406
|
+
modifier = Modifier
|
|
407
|
+
.fillMaxSize()
|
|
408
|
+
// iPhone Duo: clear of the side toolbar strip, like the screen content
|
|
409
|
+
.conditional(layout.isIPhoneDuo) {
|
|
410
|
+
absolutePadding(left = layout.insetLeft, right = layout.insetRight)
|
|
411
|
+
},
|
|
412
|
+
contentAlignment = Alignment.BottomCenter
|
|
413
|
+
) {
|
|
414
|
+
overplay.content()
|
|
415
|
+
}
|
|
393
416
|
}
|
|
394
|
-
}
|
|
395
417
|
|
|
396
|
-
|
|
418
|
+
null -> {}
|
|
419
|
+
}
|
|
397
420
|
}
|
|
398
421
|
}
|
|
399
422
|
}
|
|
@@ -21,6 +21,8 @@ import androidx.compose.ui.backhandler.BackHandler as ComposeBackHandler
|
|
|
21
21
|
import androidx.compose.ui.graphics.Color
|
|
22
22
|
import androidx.compose.ui.graphics.NativePaint
|
|
23
23
|
import androidx.compose.ui.graphics.toArgb
|
|
24
|
+
import androidx.compose.ui.platform.LocalDensity
|
|
25
|
+
import androidx.compose.ui.platform.LocalWindowInfo
|
|
24
26
|
import androidx.compose.ui.unit.Dp
|
|
25
27
|
import androidx.compose.ui.unit.dp
|
|
26
28
|
import androidx.compose.ui.viewinterop.UIKitInteropProperties
|
|
@@ -29,8 +31,6 @@ import cocoapods.lottie_ios.CompatibleAnimation
|
|
|
29
31
|
import cocoapods.lottie_ios.CompatibleAnimationKeypath
|
|
30
32
|
import cocoapods.lottie_ios.CompatibleAnimationView
|
|
31
33
|
import kotlinx.cinterop.ExperimentalForeignApi
|
|
32
|
-
import kotlinx.cinterop.get
|
|
33
|
-
import kotlinx.cinterop.memScoped
|
|
34
34
|
import androidx.navigationevent.NavigationEventDispatcher
|
|
35
35
|
import androidx.navigationevent.NavigationEventDispatcherOwner
|
|
36
36
|
import org.jetbrains.skia.FilterBlurMode
|
|
@@ -38,7 +38,6 @@ import org.jetbrains.skia.MaskFilter
|
|
|
38
38
|
import platform.Foundation.NSBundle
|
|
39
39
|
import platform.UIKit.UIColor
|
|
40
40
|
import platform.UIKit.UIDevice
|
|
41
|
-
import platform.UIKit.UIScreen
|
|
42
41
|
import platform.UIKit.UIUserInterfaceIdiomPhone
|
|
43
42
|
import vn.momo.kits.navigation.AttachHostBackGesture
|
|
44
43
|
import vn.momo.kits.navigation.Navigator
|
|
@@ -48,15 +47,17 @@ actual fun getPlatformName(): String = "iOS"
|
|
|
48
47
|
actual fun isPhoneIdiom(): Boolean =
|
|
49
48
|
UIDevice.currentDevice.userInterfaceIdiom == UIUserInterfaceIdiomPhone
|
|
50
49
|
|
|
51
|
-
|
|
50
|
+
// AI-GENERATED START: window size, not UIScreen.main (stays on one display on iPhone Duo); recomposes on fold/unfold
|
|
52
51
|
@Composable
|
|
53
|
-
actual fun getScreenDimensions(): ScreenDimension
|
|
54
|
-
val
|
|
52
|
+
actual fun getScreenDimensions(): ScreenDimension {
|
|
53
|
+
val density = LocalDensity.current
|
|
54
|
+
val size = LocalWindowInfo.current.containerSize
|
|
55
55
|
return ScreenDimension(
|
|
56
|
-
|
|
57
|
-
|
|
56
|
+
(size.width / density.density).toInt(),
|
|
57
|
+
(size.height / density.density).toInt()
|
|
58
58
|
)
|
|
59
59
|
}
|
|
60
|
+
// AI-GENERATED END: window size, not UIScreen.main (stays on one display on iPhone Duo); recomposes on fold/unfold
|
|
60
61
|
|
|
61
62
|
actual fun NativePaint.setMaskFilter(blurRadius: Float) {
|
|
62
63
|
this.maskFilter = MaskFilter.makeBlur(FilterBlurMode.NORMAL, blurRadius / 2, true)
|
package/gradle.properties
CHANGED
|
@@ -36,6 +36,10 @@ public final class DesignSystemKits: ObservableObject {
|
|
|
36
36
|
|
|
37
37
|
@Published private var configs = DesignSystemKitsConfigs()
|
|
38
38
|
@Published public private(set) var osFontScale: CGFloat = DesignSystemKits.currentOsFontScale()
|
|
39
|
+
// AI-GENERATED START: republish the font scale when the window scene is resized (iPhone Duo fold/unfold)
|
|
40
|
+
@Published public private(set) var windowShortSide: CGFloat = DesignSystemKits.currentWindowShortSide()
|
|
41
|
+
private var geometryObservations: [ObjectIdentifier: NSKeyValueObservation] = [:]
|
|
42
|
+
// AI-GENERATED END: republish the font scale when the window scene is resized (iPhone Duo fold/unfold)
|
|
39
43
|
|
|
40
44
|
private init() {
|
|
41
45
|
NotificationCenter.default.addObserver(
|
|
@@ -44,6 +48,19 @@ public final class DesignSystemKits: ObservableObject {
|
|
|
44
48
|
name: UIContentSizeCategory.didChangeNotification,
|
|
45
49
|
object: nil
|
|
46
50
|
)
|
|
51
|
+
// AI-GENERATED START: republish the font scale when the window scene is resized (iPhone Duo fold/unfold)
|
|
52
|
+
NotificationCenter.default.addObserver(
|
|
53
|
+
self,
|
|
54
|
+
selector: #selector(sceneDidActivate(_:)),
|
|
55
|
+
name: UIScene.didActivateNotification,
|
|
56
|
+
object: nil
|
|
57
|
+
)
|
|
58
|
+
onMain {
|
|
59
|
+
UIApplication.shared.connectedScenes
|
|
60
|
+
.compactMap { $0 as? UIWindowScene }
|
|
61
|
+
.forEach(self.observeGeometry(of:))
|
|
62
|
+
}
|
|
63
|
+
// AI-GENERATED END: republish the font scale when the window scene is resized (iPhone Duo fold/unfold)
|
|
47
64
|
}
|
|
48
65
|
|
|
49
66
|
public func setConfigs(_ configs: DesignSystemKitsConfigs) {
|
|
@@ -62,7 +79,10 @@ public final class DesignSystemKits: ObservableObject {
|
|
|
62
79
|
FontScale(
|
|
63
80
|
userScaleRate: configs.userScaleRate,
|
|
64
81
|
useOSScaleRate: configs.useOSScaleRate,
|
|
65
|
-
osFontScale: osFontScale
|
|
82
|
+
osFontScale: osFontScale,
|
|
83
|
+
// AI-GENERATED START: republish the font scale when the window scene is resized (iPhone Duo fold/unfold)
|
|
84
|
+
windowShortSide: windowShortSide
|
|
85
|
+
// AI-GENERATED END: republish the font scale when the window scene is resized (iPhone Duo fold/unfold)
|
|
66
86
|
)
|
|
67
87
|
}
|
|
68
88
|
|
|
@@ -72,6 +92,49 @@ public final class DesignSystemKits: ObservableObject {
|
|
|
72
92
|
osFontScale = next
|
|
73
93
|
}
|
|
74
94
|
|
|
95
|
+
// AI-GENERATED START: republish the font scale when the window scene is resized (iPhone Duo fold/unfold)
|
|
96
|
+
@objc private func sceneDidActivate(_ notification: Notification) {
|
|
97
|
+
guard let scene = notification.object as? UIWindowScene else { return }
|
|
98
|
+
observeGeometry(of: scene)
|
|
99
|
+
refreshWindowShortSide()
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private func observeGeometry(of scene: UIWindowScene) {
|
|
103
|
+
guard #available(iOS 16.0, *) else { return }
|
|
104
|
+
let key = ObjectIdentifier(scene)
|
|
105
|
+
guard geometryObservations[key] == nil else { return }
|
|
106
|
+
geometryObservations[key] = scene.observe(\.effectiveGeometry, options: [.new]) { [weak self] _, _ in
|
|
107
|
+
// The window is resized after the scene geometry, so read it on the next turn.
|
|
108
|
+
DispatchQueue.main.async { self?.refreshWindowShortSide() }
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private func refreshWindowShortSide() {
|
|
113
|
+
let next = Self.currentWindowShortSide()
|
|
114
|
+
guard next > 0, next != windowShortSide else { return }
|
|
115
|
+
windowShortSide = next
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/// UIScreen.main stays on one display on iPhone Duo, so measure the foreground window instead.
|
|
119
|
+
static func currentWindowShortSide() -> CGFloat {
|
|
120
|
+
guard Thread.isMainThread else {
|
|
121
|
+
return min(UIScreen.main.bounds.width, UIScreen.main.bounds.height)
|
|
122
|
+
}
|
|
123
|
+
let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
|
|
124
|
+
let scene = scenes.first { $0.activationState == .foregroundActive } ?? scenes.first
|
|
125
|
+
let window = scene?.windows.first { $0.isKeyWindow } ?? scene?.windows.first
|
|
126
|
+
let size: CGSize
|
|
127
|
+
if let window, window.bounds.width > 0 {
|
|
128
|
+
size = window.bounds.size
|
|
129
|
+
} else if let scene {
|
|
130
|
+
size = scene.coordinateSpace.bounds.size
|
|
131
|
+
} else {
|
|
132
|
+
size = UIScreen.main.bounds.size
|
|
133
|
+
}
|
|
134
|
+
return min(size.width, size.height)
|
|
135
|
+
}
|
|
136
|
+
// AI-GENERATED END: republish the font scale when the window scene is resized (iPhone Duo fold/unfold)
|
|
137
|
+
|
|
75
138
|
/// Same factor Compose's iOS `ProvideOsFontScale` derives from
|
|
76
139
|
/// `UIFont.preferredFont(forTextStyle: .body).pointSize / 17`, so both iOS surfaces scale alike.
|
|
77
140
|
static func currentOsFontScale() -> CGFloat {
|
|
@@ -32,7 +32,8 @@ public struct ScreenLayout: Equatable {
|
|
|
32
32
|
public let width: CGFloat
|
|
33
33
|
public let height: CGFloat
|
|
34
34
|
public let isIPhoneDuo: Bool
|
|
35
|
-
///
|
|
35
|
+
/// iPhone Duo folded or not, whatever the window size (its hinge, or the outer display's one-sided strip);
|
|
36
|
+
/// drives popups, modal and bottom sheet.
|
|
36
37
|
public let isDuoDevice: Bool
|
|
37
38
|
public let insets: EdgeInsets
|
|
38
39
|
public let insetLeft: CGFloat
|
|
@@ -75,6 +76,11 @@ enum DuoMetrics {
|
|
|
75
76
|
static let floatingTabBarHeight: CGFloat = 62
|
|
76
77
|
static let floatingTabBarMargin: CGFloat = 20
|
|
77
78
|
static let floatingTabBarInsetOverlap: CGFloat = 13
|
|
79
|
+
// Width of a system alert on the inner display, in both orientations.
|
|
80
|
+
static let modalMaxWidth: CGFloat = 440
|
|
81
|
+
// Fits a PopupNotify with image, scrolled description and actions at the max width (RN
|
|
82
|
+
// Application/device.ts); taller content scrolls instead of filling the inner display.
|
|
83
|
+
static let modalMaxHeight: CGFloat = 640
|
|
78
84
|
}
|
|
79
85
|
|
|
80
86
|
enum HeaderMetrics {
|
|
@@ -178,8 +184,10 @@ extension ScreenLayout {
|
|
|
178
184
|
)
|
|
179
185
|
}
|
|
180
186
|
|
|
187
|
+
// the outer display reports no hinge; its one-sided strip still gives it away
|
|
181
188
|
return ScreenLayout(
|
|
182
|
-
width: width, height: height, isIPhoneDuo: true,
|
|
189
|
+
width: width, height: height, isIPhoneDuo: true,
|
|
190
|
+
isDuoDevice: isDuoDevice || insets.leading != insets.trailing, insets: insets,
|
|
183
191
|
insetLeft: insets.leading, insetRight: insets.trailing,
|
|
184
192
|
contentWidth: width - insets.leading - insets.trailing,
|
|
185
193
|
headerHeight: headerHeight, headerStatusBarHeight: headerStatusBarHeight, sideBar: sideBar,
|
|
@@ -256,12 +264,24 @@ extension ScreenLayout {
|
|
|
256
264
|
public var duoTopInset: CGFloat {
|
|
257
265
|
max(insets.top, height > width ? max(insets.leading, insets.trailing) * 2 : 0)
|
|
258
266
|
}
|
|
267
|
+
|
|
268
|
+
/// Room for a dialog on iPhone Duo: inside the safe area, so clear of the side toolbar strip, and
|
|
269
|
+
/// capped so it stays a dialog on the inner display.
|
|
270
|
+
var duoModalMaxWidth: CGFloat {
|
|
271
|
+
min(width - insets.leading - insets.trailing - Spacing.XL * 2, DuoMetrics.modalMaxWidth)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
var duoModalMaxHeight: CGFloat {
|
|
275
|
+
min(height - insets.top - insets.bottom - Spacing.XL * 2, DuoMetrics.modalMaxHeight)
|
|
276
|
+
}
|
|
259
277
|
}
|
|
260
278
|
|
|
261
279
|
/// Snapshot for call sites without `\.screenLayout`; not reactive. `windowSize` is ignored,
|
|
262
280
|
/// kept for host call sites.
|
|
263
281
|
public func isIPhoneDuoDevice(windowSize: CGSize? = nil) -> Bool {
|
|
264
|
-
isPhoneIdiom
|
|
282
|
+
guard isPhoneIdiom else { return false }
|
|
283
|
+
let insets = keyWindow?.safeAreaInsets
|
|
284
|
+
return FoldableDevice.isFoldable(measuredIn: keyWindow) || insets.map { $0.left != $0.right } == true
|
|
265
285
|
}
|
|
266
286
|
|
|
267
287
|
/// Apple's foldable check (as the host's `SideTabRailView.isFoldableDevice`): only a device with a
|
|
@@ -15,16 +15,25 @@ import SwiftUI
|
|
|
15
15
|
|
|
16
16
|
/// Mirrors Compose's `sealed class BottomHeader` (Title / Custom).
|
|
17
17
|
public struct BottomSheetHeader {
|
|
18
|
+
struct Back {
|
|
19
|
+
let icon: String
|
|
20
|
+
let action: () -> Void
|
|
21
|
+
}
|
|
22
|
+
|
|
18
23
|
enum Style {
|
|
19
|
-
case title(String)
|
|
24
|
+
case title(String, back: Back?)
|
|
20
25
|
case custom(() -> AnyView)
|
|
21
26
|
}
|
|
22
27
|
|
|
23
28
|
let style: Style
|
|
24
29
|
|
|
25
|
-
/// Mirrors `BottomHeader.Title`.
|
|
26
|
-
public static func title(
|
|
27
|
-
|
|
30
|
+
/// Mirrors `BottomHeader.Title`. `onBack` shows a back button; the sheet closes, then it runs.
|
|
31
|
+
public static func title(
|
|
32
|
+
_ text: String = "Bottom Sheet Title",
|
|
33
|
+
backIcon: String = "arrow-back",
|
|
34
|
+
onBack: (() -> Void)? = nil
|
|
35
|
+
) -> BottomSheetHeader {
|
|
36
|
+
BottomSheetHeader(style: .title(text, back: onBack.map { Back(icon: backIcon, action: $0) }))
|
|
28
37
|
}
|
|
29
38
|
|
|
30
39
|
/// Mirrors `BottomHeader.Custom`.
|
|
@@ -68,6 +77,9 @@ struct BottomSheetView<Content: View>: View {
|
|
|
68
77
|
/// Mirrors Compose's `sheetCloseOffset` (100dp drag threshold to dismiss).
|
|
69
78
|
private let closeThreshold: CGFloat = 100
|
|
70
79
|
|
|
80
|
+
private let indicatorHeight: CGFloat = 16
|
|
81
|
+
private let headerRowHeight: CGFloat = 56
|
|
82
|
+
|
|
71
83
|
/// Mirrors Compose's per-term peak (0.3f). Combined with `backgroundAlpha` the
|
|
72
84
|
/// steady-state (fully open) scrim peaks at 0.6, matching ModalScreen's peak.
|
|
73
85
|
private let maxDynamicAlpha: CGFloat = 0.3
|
|
@@ -78,6 +90,21 @@ struct BottomSheetView<Content: View>: View {
|
|
|
78
90
|
private var isDuo: Bool { layout.isDuoDevice }
|
|
79
91
|
// iPhone Duo inner display: center the sheet instead of spanning the width
|
|
80
92
|
private var isDuoCentered: Bool { isDuo && layout.width >= 600 }
|
|
93
|
+
/// Outer display: the sheet floats Spacing.M off the side edges like system sheets, its content
|
|
94
|
+
/// stays in the safe area and its buttons move to the side bar column (HIG).
|
|
95
|
+
private var isDuoFloating: Bool { isDuo && !isDuoCentered }
|
|
96
|
+
|
|
97
|
+
private var titleBack: BottomSheetHeader.Back? {
|
|
98
|
+
if case .title(_, let back) = header.style { return back }
|
|
99
|
+
return nil
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private var sideColumn: SideBarLayout? {
|
|
103
|
+
guard isDuoFloating, case .title = header.style, let sideBar = layout.sideBar,
|
|
104
|
+
sideBar.width - Spacing.M >= DuoMetrics.controlSize + Spacing.M
|
|
105
|
+
else { return nil }
|
|
106
|
+
return sideBar
|
|
107
|
+
}
|
|
81
108
|
|
|
82
109
|
/// Keeps the portrait width in landscape, like system sheets.
|
|
83
110
|
private var duoCenteredWidth: CGFloat {
|
|
@@ -87,18 +114,19 @@ struct BottomSheetView<Content: View>: View {
|
|
|
87
114
|
)
|
|
88
115
|
}
|
|
89
116
|
|
|
90
|
-
///
|
|
91
|
-
/// side status bar only covers the top corner.
|
|
117
|
+
/// The floating sheet spans the side safe area, its content stays clear of it.
|
|
92
118
|
private var sideInsetLeading: CGFloat {
|
|
93
|
-
|
|
119
|
+
isDuoFloating ? max(layout.insets.leading - Spacing.M, 0) : 0
|
|
94
120
|
}
|
|
95
121
|
|
|
96
122
|
private var sideInsetTrailing: CGFloat {
|
|
97
|
-
|
|
123
|
+
isDuoFloating ? max(layout.insets.trailing - Spacing.M, 0) : 0
|
|
98
124
|
}
|
|
99
125
|
|
|
100
126
|
private var maxHeight: CGFloat {
|
|
101
|
-
isDuo
|
|
127
|
+
guard isDuo else { return screenHeight - 90 }
|
|
128
|
+
// with its buttons in the side column the sheet can pass the side status bar
|
|
129
|
+
return layout.height - max(sideColumn != nil ? layout.insets.top : layout.duoTopInset, Spacing.XL)
|
|
102
130
|
}
|
|
103
131
|
private var bottomInset: CGFloat { useBottomInset ? safeAreaBottomInset() : 0 }
|
|
104
132
|
/// While the keyboard is up, it already covers the home-indicator area, so it
|
|
@@ -170,19 +198,21 @@ struct BottomSheetView<Content: View>: View {
|
|
|
170
198
|
.frame(width: 40, height: 4)
|
|
171
199
|
Spacer().frame(height: 4)
|
|
172
200
|
headerView
|
|
173
|
-
.frame(height:
|
|
201
|
+
.frame(height: headerRowHeight)
|
|
174
202
|
.padding(.horizontal, Spacing.M)
|
|
175
|
-
.padding(.leading, sideInsetLeading)
|
|
176
|
-
.padding(.trailing, sideInsetTrailing)
|
|
177
203
|
}
|
|
204
|
+
.padding(.leading, sideInsetLeading)
|
|
205
|
+
.padding(.trailing, sideInsetTrailing)
|
|
178
206
|
.frame(maxWidth: .infinity)
|
|
179
|
-
.frame(height:
|
|
207
|
+
.frame(height: indicatorHeight + headerRowHeight)
|
|
180
208
|
.contentShape(Rectangle())
|
|
181
209
|
.gesture(dragGesture, including: draggable ? .all : .subviews)
|
|
182
210
|
|
|
183
211
|
Rectangle()
|
|
184
212
|
.fill(theme.colors.border.default)
|
|
185
213
|
.frame(height: 1)
|
|
214
|
+
.padding(.leading, sideInsetLeading)
|
|
215
|
+
.padding(.trailing, sideInsetTrailing)
|
|
186
216
|
|
|
187
217
|
content()
|
|
188
218
|
.padding(.leading, sideInsetLeading)
|
|
@@ -191,7 +221,7 @@ struct BottomSheetView<Content: View>: View {
|
|
|
191
221
|
.layoutPriority(-1)
|
|
192
222
|
|
|
193
223
|
if let footer {
|
|
194
|
-
BottomSheetFooter(content: footer)
|
|
224
|
+
BottomSheetFooter(content: footer, leadingInset: sideInsetLeading, trailingInset: sideInsetTrailing)
|
|
195
225
|
}
|
|
196
226
|
}
|
|
197
227
|
// The filler stays outside the cap, which shrinks by it instead: the sheet still ends at
|
|
@@ -209,27 +239,61 @@ struct BottomSheetView<Content: View>: View {
|
|
|
209
239
|
}
|
|
210
240
|
)
|
|
211
241
|
.background(backgroundColor)
|
|
212
|
-
.
|
|
242
|
+
.overlay {
|
|
243
|
+
if let sideColumn {
|
|
244
|
+
BottomSheetSideColumn(
|
|
245
|
+
sideBar: sideColumn,
|
|
246
|
+
windowHeight: layout.height,
|
|
247
|
+
topOffset: indicatorHeight + (headerRowHeight - DuoMetrics.controlSize) / 2,
|
|
248
|
+
back: titleBack,
|
|
249
|
+
onBack: goBack,
|
|
250
|
+
onClose: { close() }
|
|
251
|
+
)
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
.clipShape(RoundedCorner(radius: isDuo ? Radius.XL : Radius.M, corners: [.topLeft, .topRight]))
|
|
255
|
+
.padding(.horizontal, isDuoFloating ? Spacing.M : 0)
|
|
213
256
|
}
|
|
214
257
|
|
|
215
258
|
@ViewBuilder
|
|
216
259
|
private var headerView: some View {
|
|
217
260
|
switch header.style {
|
|
218
|
-
case .title(let text):
|
|
261
|
+
case .title(let text, let back):
|
|
262
|
+
// the side column holds back and close, the row keeps only the title
|
|
263
|
+
let showsButtons = sideColumn == nil
|
|
219
264
|
HStack(spacing: 0) {
|
|
220
|
-
|
|
265
|
+
if showsButtons {
|
|
266
|
+
if let back {
|
|
267
|
+
headerButton(icon: back.icon, accessibilityId: "btn_bottom_sheet_back") { goBack(back) }
|
|
268
|
+
} else {
|
|
269
|
+
Color.clear.frame(width: 24, height: 24)
|
|
270
|
+
}
|
|
271
|
+
}
|
|
221
272
|
MomoText(text, typography: .headerDefaultBold, color: Colors.black17)
|
|
222
273
|
.frame(maxWidth: .infinity, alignment: .center)
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
274
|
+
.padding(.horizontal, showsButtons ? 0 : Spacing.M)
|
|
275
|
+
if showsButtons {
|
|
276
|
+
headerButton(icon: "navigation_close", accessibilityId: "btn_bottom_sheet_close") { close() }
|
|
277
|
+
}
|
|
227
278
|
}
|
|
228
279
|
case .custom(let builder):
|
|
229
280
|
builder()
|
|
230
281
|
}
|
|
231
282
|
}
|
|
232
283
|
|
|
284
|
+
private func headerButton(icon: String, accessibilityId: String, action: @escaping () -> Void) -> some View {
|
|
285
|
+
Icon(source: icon, size: 24, color: Colors.black17)
|
|
286
|
+
.frame(width: 24, height: 24)
|
|
287
|
+
.contentShape(Rectangle())
|
|
288
|
+
.onTapGesture(perform: action)
|
|
289
|
+
.accessibilityIdentifier(accessibilityId)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/// RN `leftOptions.onPressIconLeft`: the sheet closes first, then the back runs.
|
|
293
|
+
private func goBack(_ back: BottomSheetHeader.Back) {
|
|
294
|
+
close(then: back.action)
|
|
295
|
+
}
|
|
296
|
+
|
|
233
297
|
// MARK: - Drag handling (mirrors Compose's detectDragGestures)
|
|
234
298
|
|
|
235
299
|
private var dragGesture: some Gesture {
|
|
@@ -282,7 +346,7 @@ struct BottomSheetView<Content: View>: View {
|
|
|
282
346
|
|
|
283
347
|
/// Mirrors Compose's `closeEvent`: slide out over 200ms, then fade the scrim
|
|
284
348
|
/// out over 100ms only after the slide finishes, then dismiss.
|
|
285
|
-
private func close() {
|
|
349
|
+
private func close(then onClosed: (() -> Void)? = nil) {
|
|
286
350
|
guard !isClosing else { return }
|
|
287
351
|
isClosing = true
|
|
288
352
|
withAnimation(.timingCurve(0.3, 0, 0.8, 0.15, duration: 0.2)) {
|
|
@@ -294,7 +358,67 @@ struct BottomSheetView<Content: View>: View {
|
|
|
294
358
|
}
|
|
295
359
|
}
|
|
296
360
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
|
361
|
+
// a newer overlay replaced this one: it stays, and this sheet's back is dropped
|
|
362
|
+
guard navigator.overplay?.id == overplayId else { return }
|
|
297
363
|
navigator.finishOverplayDismiss(id: overplayId)
|
|
364
|
+
onClosed?()
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/// iPhone Duo outer display: back and close sit on the part of the sheet under the side safe area,
|
|
370
|
+
/// 12pt from the strip's inner edge like the screen side bar. They follow the header row while the
|
|
371
|
+
/// sheet is short and stay below the status bar cluster while it is tall.
|
|
372
|
+
private struct BottomSheetSideColumn: View {
|
|
373
|
+
let sideBar: SideBarLayout
|
|
374
|
+
let windowHeight: CGFloat
|
|
375
|
+
/// Lines the controls up with the header row.
|
|
376
|
+
let topOffset: CGFloat
|
|
377
|
+
let back: BottomSheetHeader.Back?
|
|
378
|
+
let onBack: (BottomSheetHeader.Back) -> Void
|
|
379
|
+
let onClose: () -> Void
|
|
380
|
+
|
|
381
|
+
@Environment(\.appTheme) private var theme
|
|
382
|
+
|
|
383
|
+
private var isRight: Bool { sideBar.edge == .right }
|
|
384
|
+
|
|
385
|
+
private var columnHeight: CGFloat {
|
|
386
|
+
let count: CGFloat = back != nil ? 2 : 1
|
|
387
|
+
return count * DuoMetrics.controlSize + (count - 1) * Spacing.M
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
private var headerColor: HeaderColor {
|
|
391
|
+
getHeaderColor(isAnimated: false, isSurface: true, opacity: 1, tintColor: nil, defaultColor: theme.colors.text.default)
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
var body: some View {
|
|
395
|
+
GeometryReader { proxy in
|
|
396
|
+
let sheetHeight = proxy.size.height
|
|
397
|
+
// the sheet rests on the window bottom; its slide is an offset outside this layout
|
|
398
|
+
let sheetTop = windowHeight - sheetHeight
|
|
399
|
+
// clear of a camera at the bottom end of the strip when it can
|
|
400
|
+
let lowest = sheetHeight - sideBar.bottom - columnHeight
|
|
401
|
+
let top = max(min(max(sideBar.top - sheetTop, topOffset), lowest), 0)
|
|
402
|
+
VStack(alignment: isRight ? .leading : .trailing, spacing: Spacing.M) {
|
|
403
|
+
if let back {
|
|
404
|
+
SideBarButton(
|
|
405
|
+
icon: back.icon,
|
|
406
|
+
headerColor: headerColor,
|
|
407
|
+
accessibilityId: "btn_bottom_sheet_back",
|
|
408
|
+
action: { onBack(back) }
|
|
409
|
+
)
|
|
410
|
+
}
|
|
411
|
+
SideBarButton(
|
|
412
|
+
icon: "navigation_close",
|
|
413
|
+
headerColor: headerColor,
|
|
414
|
+
accessibilityId: "btn_bottom_sheet_close",
|
|
415
|
+
action: onClose
|
|
416
|
+
)
|
|
417
|
+
}
|
|
418
|
+
.padding(isRight ? .leading : .trailing, Spacing.M)
|
|
419
|
+
.frame(width: sideBar.width - Spacing.M, alignment: isRight ? .leading : .trailing)
|
|
420
|
+
.padding(.top, top)
|
|
421
|
+
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: isRight ? .topTrailing : .topLeading)
|
|
298
422
|
}
|
|
299
423
|
}
|
|
300
424
|
}
|
|
@@ -371,12 +495,16 @@ private struct CapHeightLayout: Layout {
|
|
|
371
495
|
/// Mirrors Compose's `BottomSheetFooter`: a surface band with a soft shadow cast on the content.
|
|
372
496
|
private struct BottomSheetFooter: View {
|
|
373
497
|
let content: () -> AnyView
|
|
498
|
+
var leadingInset: CGFloat = 0
|
|
499
|
+
var trailingInset: CGFloat = 0
|
|
374
500
|
|
|
375
501
|
@Environment(\.appTheme) private var theme
|
|
376
502
|
|
|
377
503
|
var body: some View {
|
|
378
504
|
content()
|
|
379
505
|
.frame(maxWidth: .infinity)
|
|
506
|
+
.padding(.leading, leadingInset)
|
|
507
|
+
.padding(.trailing, trailingInset)
|
|
380
508
|
.padding(.vertical, Spacing.S)
|
|
381
509
|
.background(theme.colors.background.surface)
|
|
382
510
|
.overlay(alignment: .top) {
|