@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
package/CLAUDE.md
CHANGED
|
@@ -71,7 +71,7 @@ Publishing is automated (`publish.sh` + GitLab CI on `[ci build]` / `main`). Do
|
|
|
71
71
|
## Screen layout / iPhone Duo (port of RN `useScreenLayout`, momo-kits !664)
|
|
72
72
|
|
|
73
73
|
- `layout/ScreenLayout.kt`: `getScreenLayout()` returns window size, raw top/bottom + side insets, `contentWidth`, header geometry, the iPhone Duo `sideBar` and `duoTopInset`. `NavigationContainer` computes it once and provides `LocalScreenLayout`; reactive to fold/unfold and rotation (window size + `WindowInsets`). Metrics (`DuoMetrics`, 72pt iPhone header floor, 20pt status-bar threshold) mirror RN `Layout/utils.ts` — change both together.
|
|
74
|
-
- iPhone-only: everything gates on `isPhoneIdiom()` (`platform/`), never on "iOS", so iPad windows keep the regular layout. Two detectors, as in RN: `isIPhoneDuo` (window aspect < 1.6 or asymmetric side insets) drives layout; `isIPhoneDuoDevice` (Apple's foldable check: a `.division` reserved region, queried with include-inactive; Swift also latches on `UIHingeInteraction
|
|
74
|
+
- iPhone-only: everything gates on `isPhoneIdiom()` (`platform/`), never on "iOS", so iPad windows keep the regular layout. Two detectors, as in RN: `isIPhoneDuo` (window aspect < 1.6 or asymmetric side insets) drives layout; `isIPhoneDuoDevice` (Apple's foldable check: a `.division` reserved region, queried with include-inactive; Swift also latches on `UIHingeInteraction`; or asymmetric side insets, since the outer display reports no hinge) drives popups, modal and bottom sheet.
|
|
75
75
|
- Header height is `headerHeight` (not `AppStatusBar + HEADER_HEIGHT`); the bar row is `headerBarHeight` and starts `headerStatusBarHeight` below the window top. When the status bar is off the top edge (iPhone landscape, iPhone Duo) `headerStatusBarHeight` is 0 so the title stays centered — except on the Duo outer display: in portrait the header is 76pt and the row starts at 14pt so the title centers on the camera punchout; in landscape the row centers on the camera when it sits at the top end of the strip (else on the back button) and the header reaches 8pt below the back button.
|
|
76
76
|
- iPhone Duo inner display in portrait (status bar on top): the header row shares the status bar band — `headerHeight` 94pt (2 × the clock digits' y 47), `headerStatusBarHeight` 0 — and `ScreenLayout.statusBarClearance` keeps it between the clock / battery clusters (from reserved regions, else 128pt on the right). Back and toolkit use the 48pt side bar controls laid out horizontally (`ToolkitLayout.SideBarRow`); the collapsed search and a centered title stay clear of the clusters. Port of momo-kits `90af33ba2`.
|
|
77
77
|
- iPhone Duo with a side strip ≥ 60pt: back + toolkit leave the bar for `HeaderSideBar` (`navigation/component/SideBar.kt`, 48pt glass controls); custom header-right stays in the bar. `sideBar.top` is 170pt in portrait, 120pt on the inner display in landscape (height ≥ 600), 24pt on the outer display in landscape, or below the header when the status bar is on top; UIKit's `statusBarFrame` is useless on Duo. Unlike RN the column is clamped above footer (24pt gap) / rail (12pt gap).
|
|
@@ -80,8 +80,10 @@ Publishing is automated (`publish.sh` + GitLab CI on `[ci build]` / `main`). Do
|
|
|
80
80
|
- Bar compression (`bottomtab/DuoBarCompression.kt`, HIG "Vertical controls"): when toolbar + rail don't fit in the strip, one is compressed — the toolkit into a `navigation_menu` button, the rail into one button showing the current tab — and tapping it swaps them; `BottomTab(compressionBehavior)` picks which starts compressed. If neither fits beside the other compressed, both rest compressed and a tap opens one for a moment. Side bar columns report their full height (0 when the screen puts nothing there) so the decision follows each screen's toolkit.
|
|
81
81
|
- Glass header: on Duo with a side bar, Default/Extended headers without search and with a `ScrollState` let content scroll under the bar (`usesDuoGlassHeader`); the bar fades in a translucent surface + hairline over 16pt (Compose has no backdrop blur — RN's fallback look), the gradient leaves with the content.
|
|
82
82
|
- Quick return header: glass headers and Animated headers (with a `ScrollState`) on the Duo side bar layout slide out with the content and back in on a reverse scroll; a half-shown header settles in or out once the scroll stops. The side bar is drawn apart from the header, so it stays put.
|
|
83
|
-
- Overlays on Duo:
|
|
83
|
+
- Overlays on Duo: the modal sits inside the safe area (all four insets, so clear of the side toolbar strip) and caps its content at `duoModalMaxWidth` / `duoModalMaxHeight` (440 × 640pt, at least XL off the safe area); popups fill that width with radius XL and drop their own side padding (body scrolls, actions stay). Snackbars keep clear of the strip.
|
|
84
|
+
- Bottom sheet on Duo: radius XL, window height, centered at width ≥ 600 (inner display, buttons stay in the header row). Below that (outer display) it floats `Spacing.M` off the side edges, pads header/content/footer by `inset − M`, and a `Title` header moves back / close into 48pt side bar buttons on the strip part of the sheet (HIG: sheet controls go vertical on the outer display) when the strip fits one; they follow the header row while the sheet is short and stay below `sideBar.top` when it is tall. `BottomHeader.Title(onBack)` / `.title(onBack:)` closes the sheet, then runs (RN `leftOptions.onPressIconLeft`).
|
|
84
85
|
- Font scale deliberately uses the short side (`min(w, h)`), unlike RN which reads the window width — landscape must not inflate every font.
|
|
86
|
+
- Font scale measures the **window**, never `UIScreen.main`: on iPhone Duo the main screen stays on one display while the window follows the one in use. Compose iOS `getScreenDimensions()` reads `LocalWindowInfo.containerSize` (recomposes on fold/unfold); SwiftUI `DesignSystemKits` KVO-observes each `UIWindowScene.effectiveGeometry` and republishes `windowShortSide`.
|
|
85
87
|
- `StackScreen` pads content/footer/search/FAB by the side insets and provides an inset-consumed layout to its content; a bottom-tab root stays full width (tab bar + each tab's StackScreen handle insets). Grid columns use `contentWidth`.
|
|
86
88
|
|
|
87
89
|
## i18n / Localize (port of RN `Localize`)
|
package/compose/build.gradle.kts
CHANGED
package/compose/compose.podspec
CHANGED
|
@@ -22,6 +22,8 @@ import vn.momo.kits.application.LocalLocalize
|
|
|
22
22
|
import vn.momo.kits.const.*
|
|
23
23
|
import vn.momo.kits.modifier.conditional
|
|
24
24
|
import vn.momo.kits.modifier.setAutomationId
|
|
25
|
+
import vn.momo.kits.layout.duoModalMaxHeight
|
|
26
|
+
import vn.momo.kits.layout.duoModalMaxWidth
|
|
25
27
|
import vn.momo.kits.layout.getScreenLayout
|
|
26
28
|
import kotlin.math.min
|
|
27
29
|
|
|
@@ -36,22 +38,29 @@ enum class PopupActionDirection {
|
|
|
36
38
|
AUTO
|
|
37
39
|
}
|
|
38
40
|
|
|
39
|
-
//
|
|
40
|
-
|
|
41
|
+
// Card width cap on iPhone Duo (the modal bounds, RN Popup/utils.ts); unbounded elsewhere.
|
|
42
|
+
@Composable
|
|
43
|
+
internal fun popupMaxWidth(): Dp {
|
|
44
|
+
val layout = getScreenLayout()
|
|
45
|
+
return if (layout.isIPhoneDuoDevice) layout.duoModalMaxWidth else Dp.Unspecified
|
|
46
|
+
}
|
|
41
47
|
|
|
42
|
-
//
|
|
48
|
+
// The modal bounds already keep iPhone Duo popups off the edges, so their own side padding goes.
|
|
43
49
|
@Composable
|
|
44
|
-
internal fun
|
|
45
|
-
if (getScreenLayout().isIPhoneDuoDevice)
|
|
50
|
+
internal fun popupHorizontalPadding(padding: Dp): Dp =
|
|
51
|
+
if (getScreenLayout().isIPhoneDuoDevice) 0.dp else padding
|
|
46
52
|
|
|
47
|
-
//
|
|
53
|
+
// Tallest a popup gets on iPhone Duo, landscape and the inner display included; null elsewhere.
|
|
48
54
|
@Composable
|
|
49
55
|
internal fun popupMaxHeight(): Dp? {
|
|
50
56
|
val layout = getScreenLayout()
|
|
51
|
-
if (
|
|
52
|
-
return layout.height - layout.duoTopInset - layout.insetBottom - Spacing.XL * 2
|
|
57
|
+
return if (layout.isIPhoneDuoDevice) layout.duoModalMaxHeight else null
|
|
53
58
|
}
|
|
54
59
|
|
|
60
|
+
// Rounder on iPhone Duo, closer to its system alerts.
|
|
61
|
+
@Composable
|
|
62
|
+
internal fun popupRadius(): Dp = if (getScreenLayout().isIPhoneDuoDevice) Radius.XL else Radius.L
|
|
63
|
+
|
|
55
64
|
private fun heightForLines(
|
|
56
65
|
layout: TextLayoutResult,
|
|
57
66
|
): Float {
|
|
@@ -145,20 +154,19 @@ fun PopupNotify(
|
|
|
145
154
|
|
|
146
155
|
val maxHeight = popupMaxHeight()
|
|
147
156
|
val bodyScrollState = rememberScrollState()
|
|
157
|
+
val horizontalPadding = popupHorizontalPadding(12.dp)
|
|
158
|
+
val shape = RoundedCornerShape(popupRadius())
|
|
148
159
|
|
|
149
160
|
Box(
|
|
150
161
|
Modifier.setAutomationId("popup_notify"), contentAlignment = Alignment.TopEnd
|
|
151
162
|
) {
|
|
152
163
|
Column(
|
|
153
164
|
modifier = Modifier
|
|
154
|
-
.widthIn(max = popupMaxWidth(
|
|
165
|
+
.widthIn(max = popupMaxWidth())
|
|
155
166
|
.fillMaxWidth()
|
|
156
|
-
.padding(horizontal =
|
|
157
|
-
.clip(
|
|
158
|
-
.background(
|
|
159
|
-
AppTheme.current.colors.background.surface,
|
|
160
|
-
RoundedCornerShape(Radius.L)
|
|
161
|
-
)
|
|
167
|
+
.padding(horizontal = horizontalPadding)
|
|
168
|
+
.clip(shape)
|
|
169
|
+
.background(AppTheme.current.colors.background.surface, shape)
|
|
162
170
|
.conditional(IsShowBaseLineDebug) {
|
|
163
171
|
border(1.dp, Colors.blue_03)
|
|
164
172
|
}
|
|
@@ -215,7 +223,8 @@ fun PopupNotify(
|
|
|
215
223
|
Modifier
|
|
216
224
|
.width(22.dp)
|
|
217
225
|
.height(22.dp)
|
|
218
|
-
|
|
226
|
+
// centered on the card's top right corner
|
|
227
|
+
.offset(x = 11.dp - horizontalPadding, y = (-11).dp)
|
|
219
228
|
.background(
|
|
220
229
|
color = AppTheme.current.colors.text.default,
|
|
221
230
|
shape = RoundedCornerShape(Radius.M)
|
|
@@ -32,15 +32,15 @@ fun PopupPromotion(
|
|
|
32
32
|
onClose: (() -> Unit) = {},
|
|
33
33
|
) {
|
|
34
34
|
val maxHeight = popupMaxHeight()
|
|
35
|
-
var maxWidth = popupMaxWidth(
|
|
35
|
+
var maxWidth = popupMaxWidth()
|
|
36
36
|
if (maxHeight != null) {
|
|
37
37
|
// fit the portrait image by height when iPhone Duo leaves too little room
|
|
38
|
-
maxWidth = minOf(maxWidth, (maxHeight - CLOSE_ROW_HEIGHT) * ASPECT_RATIO
|
|
38
|
+
maxWidth = minOf(maxWidth, (maxHeight - CLOSE_ROW_HEIGHT) * ASPECT_RATIO)
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
Column(Modifier
|
|
42
42
|
.widthIn(max = maxWidth)
|
|
43
|
-
.padding(horizontal = Spacing.M)
|
|
43
|
+
.padding(horizontal = popupHorizontalPadding(Spacing.M))
|
|
44
44
|
.conditional(IsShowBaseLineDebug) {
|
|
45
45
|
border(1.dp, Colors.blue_03)
|
|
46
46
|
}
|
|
@@ -51,7 +51,8 @@ data class ScreenLayout(
|
|
|
51
51
|
val headerStatusBarHeight: Dp,
|
|
52
52
|
// iPhone Duo moves back and toolkit into a column in the side safe area; null while they stay in the bar.
|
|
53
53
|
val sideBar: SideBarLayout? = null,
|
|
54
|
-
//
|
|
54
|
+
// iPhone Duo folded or not, whatever the window size (its hinge, or the outer display's one-sided strip);
|
|
55
|
+
// drives popups, modal and bottom sheet.
|
|
55
56
|
val isIPhoneDuoDevice: Boolean = isIPhoneDuo,
|
|
56
57
|
// Portrait iPhone Duo puts the status bar in the side strip, about twice as tall as the strip is wide.
|
|
57
58
|
// Stored so it survives consumeHorizontalInsets().
|
|
@@ -90,6 +91,11 @@ internal object DuoMetrics {
|
|
|
90
91
|
val floatingTabBarHeight = 62.dp
|
|
91
92
|
val floatingTabBarMargin = 20.dp
|
|
92
93
|
val floatingTabBarInsetOverlap = 13.dp
|
|
94
|
+
// Width of a system alert on the inner display, in both orientations.
|
|
95
|
+
val modalMaxWidth = 440.dp
|
|
96
|
+
// Fits a PopupNotify with image, scrolled description and actions at the max width (RN
|
|
97
|
+
// Application/device.ts); taller content scrolls instead of filling the inner display.
|
|
98
|
+
val modalMaxHeight = 640.dp
|
|
93
99
|
}
|
|
94
100
|
|
|
95
101
|
private val IPHONE_HEADER_MIN_HEIGHT = 72.dp
|
|
@@ -152,6 +158,14 @@ internal fun calculateScreenLayout(topInset: Dp, parent: ScreenLayout? = null):
|
|
|
152
158
|
}
|
|
153
159
|
}
|
|
154
160
|
|
|
161
|
+
// Room for a dialog on iPhone Duo: inside the safe area, so clear of the side toolbar strip, and
|
|
162
|
+
// capped so it stays a dialog on the inner display.
|
|
163
|
+
internal val ScreenLayout.duoModalMaxWidth: Dp
|
|
164
|
+
get() = minOf(width - insetLeft - insetRight - Spacing.XL * 2, DuoMetrics.modalMaxWidth)
|
|
165
|
+
|
|
166
|
+
internal val ScreenLayout.duoModalMaxHeight: Dp
|
|
167
|
+
get() = minOf(height - insetTop - insetBottom - Spacing.XL * 2, DuoMetrics.modalMaxHeight)
|
|
168
|
+
|
|
155
169
|
// Content inside the safe-area padding: the insets are spent and the side bar belongs to the screen.
|
|
156
170
|
internal fun ScreenLayout.consumeHorizontalInsets(): ScreenLayout =
|
|
157
171
|
copy(width = contentWidth, insetLeft = 0.dp, insetRight = 0.dp, sideBar = null)
|
|
@@ -299,7 +313,8 @@ private fun screenLayoutOf(
|
|
|
299
313
|
headerHeight = headerHeight,
|
|
300
314
|
headerStatusBarHeight = headerStatusBarHeight,
|
|
301
315
|
sideBar = sideBar,
|
|
302
|
-
|
|
316
|
+
// the outer display reports no hinge; its one-sided strip still gives it away
|
|
317
|
+
isIPhoneDuoDevice = isDuoDevice || (isPhone && asymmetricInsets),
|
|
303
318
|
duoTopInset = maxOf(topInset, if (height > width) sideInset * 2 else 0.dp),
|
|
304
319
|
statusBarClearance = statusBarClearance,
|
|
305
320
|
)
|
|
@@ -6,7 +6,9 @@ import androidx.compose.animation.core.tween
|
|
|
6
6
|
import androidx.compose.foundation.background
|
|
7
7
|
import androidx.compose.foundation.border
|
|
8
8
|
import androidx.compose.foundation.gestures.detectDragGestures
|
|
9
|
+
import androidx.compose.foundation.layout.Arrangement
|
|
9
10
|
import androidx.compose.foundation.layout.Box
|
|
11
|
+
import androidx.compose.foundation.layout.BoxScope
|
|
10
12
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
|
11
13
|
import androidx.compose.foundation.layout.Column
|
|
12
14
|
import androidx.compose.foundation.layout.Row
|
|
@@ -17,6 +19,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|
|
17
19
|
import androidx.compose.foundation.layout.height
|
|
18
20
|
import androidx.compose.foundation.layout.heightIn
|
|
19
21
|
import androidx.compose.foundation.layout.offset
|
|
22
|
+
import androidx.compose.foundation.layout.PaddingValues
|
|
20
23
|
import androidx.compose.foundation.layout.padding
|
|
21
24
|
import androidx.compose.foundation.layout.size
|
|
22
25
|
import androidx.compose.foundation.layout.width
|
|
@@ -25,15 +28,22 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|
|
25
28
|
import androidx.compose.runtime.Composable
|
|
26
29
|
import androidx.compose.runtime.DisposableEffect
|
|
27
30
|
import androidx.compose.runtime.LaunchedEffect
|
|
31
|
+
import androidx.compose.runtime.getValue
|
|
32
|
+
import androidx.compose.runtime.mutableStateOf
|
|
28
33
|
import androidx.compose.runtime.remember
|
|
34
|
+
import androidx.compose.runtime.setValue
|
|
29
35
|
import androidx.compose.runtime.rememberCoroutineScope
|
|
36
|
+
import androidx.compose.ui.AbsoluteAlignment
|
|
30
37
|
import androidx.compose.ui.Alignment
|
|
31
38
|
import androidx.compose.ui.Modifier
|
|
32
39
|
import androidx.compose.ui.graphics.Brush
|
|
33
40
|
import androidx.compose.ui.graphics.Color
|
|
34
41
|
import androidx.compose.ui.input.pointer.pointerInput
|
|
42
|
+
import androidx.compose.ui.layout.layout
|
|
35
43
|
import androidx.compose.ui.platform.LocalDensity
|
|
44
|
+
import androidx.compose.ui.unit.Dp
|
|
36
45
|
import androidx.compose.ui.unit.IntOffset
|
|
46
|
+
import androidx.compose.ui.unit.constrainHeight
|
|
37
47
|
import androidx.compose.ui.unit.dp
|
|
38
48
|
import kotlinx.coroutines.launch
|
|
39
49
|
import vn.momo.kits.components.Divider
|
|
@@ -52,11 +62,24 @@ import vn.momo.kits.modifier.conditional
|
|
|
52
62
|
import vn.momo.kits.modifier.noFeedbackClickable
|
|
53
63
|
import vn.momo.kits.platform.BackHandler
|
|
54
64
|
import vn.momo.kits.platform.getScreenHeight
|
|
65
|
+
import vn.momo.kits.layout.DuoMetrics
|
|
66
|
+
import vn.momo.kits.layout.SideBarEdge
|
|
67
|
+
import vn.momo.kits.layout.SideBarLayout
|
|
55
68
|
import vn.momo.kits.layout.getScreenLayout
|
|
69
|
+
import vn.momo.kits.modifier.setAutomationId
|
|
70
|
+
import vn.momo.kits.navigation.component.HeaderType
|
|
71
|
+
import vn.momo.kits.navigation.component.SideBarButton
|
|
72
|
+
import vn.momo.kits.navigation.component.getHeaderColor
|
|
56
73
|
|
|
57
74
|
// iPhone Duo inner display: center the sheet instead of spanning the width (RN BottomSheet).
|
|
58
75
|
private val DUO_REGULAR_MIN_WIDTH = 600.dp
|
|
59
76
|
|
|
77
|
+
private val INDICATOR_HEIGHT = 16.dp
|
|
78
|
+
private val HEADER_ROW_HEIGHT = 56.dp
|
|
79
|
+
|
|
80
|
+
// Side column controls line up with the header row while the sheet is short.
|
|
81
|
+
private val SIDE_COLUMN_OFFSET = INDICATOR_HEIGHT + (HEADER_ROW_HEIGHT - DuoMetrics.controlSize) / 2
|
|
82
|
+
|
|
60
83
|
@Composable
|
|
61
84
|
internal fun BottomSheet(
|
|
62
85
|
content: @Composable () -> Unit,
|
|
@@ -71,6 +94,22 @@ internal fun BottomSheet(
|
|
|
71
94
|
val layout = getScreenLayout()
|
|
72
95
|
val isDuo = layout.isIPhoneDuoDevice
|
|
73
96
|
val isDuoCentered = isDuo && layout.width >= DUO_REGULAR_MIN_WIDTH
|
|
97
|
+
// outer display: the sheet floats Spacing.M off the side edges like system sheets, its content
|
|
98
|
+
// stays in the safe area and its buttons move to the side bar column (HIG)
|
|
99
|
+
val isDuoFloating = isDuo && !isDuoCentered
|
|
100
|
+
val title = header as? BottomHeader.Title
|
|
101
|
+
val sideColumn = layout.sideBar?.takeIf {
|
|
102
|
+
isDuoFloating && title != null && it.width - Spacing.M >= DuoMetrics.controlSize + Spacing.M
|
|
103
|
+
}
|
|
104
|
+
// the floating sheet spans the side safe area, its content stays clear of it
|
|
105
|
+
val sidePadding = if (isDuoFloating) {
|
|
106
|
+
PaddingValues.Absolute(
|
|
107
|
+
left = (layout.insetLeft - Spacing.M).coerceAtLeast(0.dp),
|
|
108
|
+
right = (layout.insetRight - Spacing.M).coerceAtLeast(0.dp),
|
|
109
|
+
)
|
|
110
|
+
} else {
|
|
111
|
+
PaddingValues()
|
|
112
|
+
}
|
|
74
113
|
// the screen is the outer display on iPhone Duo, even when unfolded
|
|
75
114
|
val screenHeightDp = if (isDuo) layout.height else getScreenHeight()
|
|
76
115
|
val screenHeightPx = with(LocalDensity.current) { screenHeightDp.toPx() }
|
|
@@ -96,6 +135,11 @@ internal fun BottomSheet(
|
|
|
96
135
|
onDispose { }
|
|
97
136
|
}
|
|
98
137
|
|
|
138
|
+
// a closing sheet keeps going: a second close, the open slide or a drag would cancel its animation
|
|
139
|
+
var isClosing by remember { mutableStateOf(false) }
|
|
140
|
+
// runs once the sheet has closed itself, after its onDismiss
|
|
141
|
+
var afterDismiss by remember { mutableStateOf<(() -> Unit)?>(null) }
|
|
142
|
+
|
|
99
143
|
suspend fun openEvent(){
|
|
100
144
|
backgroundAlpha.animateTo(
|
|
101
145
|
targetValue = 0.3f,
|
|
@@ -104,6 +148,7 @@ internal fun BottomSheet(
|
|
|
104
148
|
easing = CubicBezierEasing(0.05f, 0.7f, 0.1f, 1f)
|
|
105
149
|
)
|
|
106
150
|
)
|
|
151
|
+
if (isClosing) return
|
|
107
152
|
sheetOffset.animateTo(
|
|
108
153
|
targetValue = 0f,
|
|
109
154
|
animationSpec = tween(
|
|
@@ -113,7 +158,9 @@ internal fun BottomSheet(
|
|
|
113
158
|
)
|
|
114
159
|
}
|
|
115
160
|
|
|
116
|
-
fun closeEvent(){
|
|
161
|
+
fun closeEvent(then: (() -> Unit)? = null){
|
|
162
|
+
if (isClosing) return
|
|
163
|
+
isClosing = true
|
|
117
164
|
coroutineScope.launch {
|
|
118
165
|
sheetOffset.animateTo(
|
|
119
166
|
targetValue = screenHeightPx,
|
|
@@ -129,10 +176,15 @@ internal fun BottomSheet(
|
|
|
129
176
|
easing = CubicBezierEasing(0.3f, 0.0f, 0.8f, 0.15f)
|
|
130
177
|
)
|
|
131
178
|
)
|
|
179
|
+
// not reached when a newer overlay replaced this one, so its back is dropped
|
|
180
|
+
afterDismiss = then
|
|
132
181
|
OverplayComponentRegistry.hardClearAfterDismiss()
|
|
133
182
|
}
|
|
134
183
|
}
|
|
135
184
|
|
|
185
|
+
// RN `leftOptions.onPressIconLeft`: the sheet closes first, then the back runs
|
|
186
|
+
val onBack = title?.onBack?.let { back -> { closeEvent(then = back) } }
|
|
187
|
+
|
|
136
188
|
LaunchedEffect(Unit) {
|
|
137
189
|
openEvent()
|
|
138
190
|
}
|
|
@@ -141,6 +193,7 @@ internal fun BottomSheet(
|
|
|
141
193
|
OverplayComponentRegistry.bindClose { closeEvent() }
|
|
142
194
|
onDispose {
|
|
143
195
|
onDismiss?.invoke()
|
|
196
|
+
afterDismiss?.invoke()
|
|
144
197
|
}
|
|
145
198
|
}
|
|
146
199
|
|
|
@@ -158,34 +211,34 @@ internal fun BottomSheet(
|
|
|
158
211
|
) {
|
|
159
212
|
val parentHeight = with(LocalDensity.current) { constraints.maxHeight.toDp() }
|
|
160
213
|
val maxHeight = if (isDuo) {
|
|
161
|
-
|
|
214
|
+
// with its buttons in the side column the sheet can pass the side status bar
|
|
215
|
+
parentHeight - maxOf(if (sideColumn != null) layout.insetTop else layout.duoTopInset, Spacing.XL)
|
|
162
216
|
} else {
|
|
163
217
|
parentHeight - 90.dp
|
|
164
218
|
}
|
|
165
|
-
|
|
166
|
-
val sideInset = isDuo && !isDuoCentered && layout.width > layout.height
|
|
219
|
+
val radius = if (isDuo) Radius.XL else Radius.M
|
|
167
220
|
|
|
168
221
|
Box(
|
|
169
222
|
modifier = Modifier
|
|
170
223
|
.align(Alignment.BottomCenter)
|
|
171
224
|
.offset { IntOffset(x = 0, y = sheetOffset.value.toInt()) }
|
|
172
225
|
.then(
|
|
173
|
-
|
|
226
|
+
when {
|
|
174
227
|
// keeps the portrait width in landscape, like system sheets
|
|
175
|
-
Modifier.width(
|
|
228
|
+
isDuoCentered -> Modifier.width(
|
|
176
229
|
minOf(
|
|
177
230
|
minOf(layout.width, layout.height) - Spacing.L * 2,
|
|
178
231
|
layout.width - maxOf(layout.insetLeft, layout.insetRight, Spacing.L) * 2,
|
|
179
232
|
)
|
|
180
233
|
)
|
|
181
|
-
|
|
182
|
-
Modifier.fillMaxWidth()
|
|
234
|
+
isDuoFloating -> Modifier.fillMaxWidth().padding(horizontal = Spacing.M)
|
|
235
|
+
else -> Modifier.fillMaxWidth()
|
|
183
236
|
}
|
|
184
237
|
)
|
|
185
238
|
.heightIn(max = maxHeight)
|
|
186
239
|
.background(
|
|
187
240
|
color = if (isSurface) AppTheme.current.colors.background.surface else AppTheme.current.colors.background.default,
|
|
188
|
-
shape = RoundedCornerShape(topStart =
|
|
241
|
+
shape = RoundedCornerShape(topStart = radius, topEnd = radius)
|
|
189
242
|
)
|
|
190
243
|
.conditional(IsShowBaseLineDebug) {
|
|
191
244
|
border(1.dp, Colors.blue_03)
|
|
@@ -196,28 +249,24 @@ internal fun BottomSheet(
|
|
|
196
249
|
val bottomInset = if (useBottomInset) {
|
|
197
250
|
(AppNavigationBar.current - keyboardSizeState().value).coerceAtLeast(0.dp)
|
|
198
251
|
} else 0.dp
|
|
199
|
-
Column(
|
|
200
|
-
Modifier
|
|
201
|
-
.padding(bottom = bottomInset)
|
|
202
|
-
.conditional(sideInset) {
|
|
203
|
-
absolutePadding(left = layout.insetLeft, right = layout.insetRight)
|
|
204
|
-
}
|
|
205
|
-
) {
|
|
252
|
+
Column(Modifier.padding(bottom = bottomInset)) {
|
|
206
253
|
Box(
|
|
207
254
|
modifier = Modifier
|
|
208
|
-
.height(
|
|
255
|
+
.height(INDICATOR_HEIGHT + HEADER_ROW_HEIGHT)
|
|
209
256
|
.fillMaxWidth()
|
|
210
257
|
.conditional(draggable) {
|
|
211
258
|
pointerInput(Unit) {
|
|
212
259
|
detectDragGestures(
|
|
213
260
|
onDrag = { change, dragAmount ->
|
|
214
261
|
change.consume()
|
|
262
|
+
if (isClosing) return@detectDragGestures
|
|
215
263
|
coroutineScope.launch {
|
|
216
264
|
val newOffset = (sheetOffset.value + dragAmount.y).coerceAtLeast(0f)
|
|
217
265
|
sheetOffset.snapTo(newOffset)
|
|
218
266
|
}
|
|
219
267
|
},
|
|
220
268
|
onDragEnd = {
|
|
269
|
+
if (isClosing) return@detectDragGestures
|
|
221
270
|
coroutineScope.launch {
|
|
222
271
|
if (sheetOffset.value > sheetCloseOffset) {
|
|
223
272
|
closeEvent()
|
|
@@ -233,6 +282,7 @@ internal fun BottomSheet(
|
|
|
233
282
|
Column(
|
|
234
283
|
modifier = Modifier
|
|
235
284
|
.fillMaxSize()
|
|
285
|
+
.padding(sidePadding)
|
|
236
286
|
.padding(horizontal = Spacing.M),
|
|
237
287
|
horizontalAlignment = Alignment.CenterHorizontally
|
|
238
288
|
) {
|
|
@@ -247,29 +297,16 @@ internal fun BottomSheet(
|
|
|
247
297
|
)
|
|
248
298
|
)
|
|
249
299
|
Spacer(Modifier.height(4.dp))
|
|
250
|
-
Box(Modifier.fillMaxWidth().height(
|
|
300
|
+
Box(Modifier.fillMaxWidth().height(HEADER_ROW_HEIGHT), contentAlignment = Alignment.Center){
|
|
251
301
|
when (header) {
|
|
252
|
-
is BottomHeader.Title ->
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
)
|
|
261
|
-
}
|
|
262
|
-
Box(Modifier
|
|
263
|
-
.size(24.dp)
|
|
264
|
-
.noFeedbackClickable {
|
|
265
|
-
closeEvent()
|
|
266
|
-
},
|
|
267
|
-
contentAlignment = Alignment.Center
|
|
268
|
-
){
|
|
269
|
-
Icon(source = "navigation_close")
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
}
|
|
302
|
+
is BottomHeader.Title -> BottomSheetTitle(
|
|
303
|
+
text = header.data,
|
|
304
|
+
backIcon = header.backIcon,
|
|
305
|
+
// the side column holds back and close, the row keeps only the title
|
|
306
|
+
showButtons = sideColumn == null,
|
|
307
|
+
onBack = onBack,
|
|
308
|
+
onClose = { closeEvent() },
|
|
309
|
+
)
|
|
273
310
|
is BottomHeader.Custom -> {
|
|
274
311
|
header.content()
|
|
275
312
|
}
|
|
@@ -277,27 +314,141 @@ internal fun BottomSheet(
|
|
|
277
314
|
}
|
|
278
315
|
}
|
|
279
316
|
}
|
|
280
|
-
|
|
317
|
+
Box(Modifier.padding(sidePadding)) {
|
|
318
|
+
Divider()
|
|
319
|
+
}
|
|
281
320
|
if (footerComponent != null) {
|
|
282
|
-
Box(Modifier.weight(1f, fill = false)) {
|
|
321
|
+
Box(Modifier.weight(1f, fill = false).padding(sidePadding)) {
|
|
283
322
|
content()
|
|
284
323
|
}
|
|
285
|
-
BottomSheetFooter(footerComponent)
|
|
324
|
+
BottomSheetFooter(footerComponent, sidePadding)
|
|
286
325
|
} else if (isDuo) {
|
|
287
326
|
// content shrinks so a tall sheet keeps its header
|
|
288
|
-
Box(Modifier.weight(1f, fill = false)) {
|
|
327
|
+
Box(Modifier.weight(1f, fill = false).padding(sidePadding)) {
|
|
289
328
|
content()
|
|
290
329
|
}
|
|
291
330
|
} else {
|
|
292
331
|
content()
|
|
293
332
|
}
|
|
294
333
|
}
|
|
334
|
+
if (sideColumn != null && title != null) {
|
|
335
|
+
BottomSheetSideColumn(
|
|
336
|
+
sideBar = sideColumn,
|
|
337
|
+
parentHeight = parentHeight,
|
|
338
|
+
backIcon = title.backIcon,
|
|
339
|
+
onBack = onBack,
|
|
340
|
+
onClose = { closeEvent() },
|
|
341
|
+
)
|
|
342
|
+
}
|
|
295
343
|
}
|
|
296
344
|
}
|
|
297
345
|
}
|
|
298
346
|
|
|
299
347
|
@Composable
|
|
300
|
-
private fun
|
|
348
|
+
private fun BottomSheetTitle(
|
|
349
|
+
text: String,
|
|
350
|
+
backIcon: String,
|
|
351
|
+
showButtons: Boolean,
|
|
352
|
+
onBack: (() -> Unit)?,
|
|
353
|
+
onClose: () -> Unit,
|
|
354
|
+
) {
|
|
355
|
+
Row(modifier = Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) {
|
|
356
|
+
if (showButtons) {
|
|
357
|
+
if (onBack != null) {
|
|
358
|
+
BottomSheetHeaderButton(icon = backIcon, automationId = "btn_bottom_sheet_back", onClick = onBack)
|
|
359
|
+
} else {
|
|
360
|
+
Box(Modifier.size(24.dp))
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
Box(
|
|
364
|
+
Modifier
|
|
365
|
+
.fillMaxHeight()
|
|
366
|
+
.weight(1f)
|
|
367
|
+
.conditional(!showButtons) { padding(horizontal = Spacing.M) },
|
|
368
|
+
contentAlignment = Alignment.Center
|
|
369
|
+
) {
|
|
370
|
+
Text(
|
|
371
|
+
text = text,
|
|
372
|
+
color = Colors.black_17,
|
|
373
|
+
style = Typography.headerDefaultBold
|
|
374
|
+
)
|
|
375
|
+
}
|
|
376
|
+
if (showButtons) {
|
|
377
|
+
BottomSheetHeaderButton(icon = "navigation_close", automationId = "btn_bottom_sheet_close", onClick = onClose)
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
@Composable
|
|
383
|
+
private fun BottomSheetHeaderButton(icon: String, automationId: String, onClick: () -> Unit) {
|
|
384
|
+
Box(
|
|
385
|
+
Modifier
|
|
386
|
+
.size(24.dp)
|
|
387
|
+
.noFeedbackClickable(onClick = onClick)
|
|
388
|
+
.setAutomationId(automationId),
|
|
389
|
+
contentAlignment = Alignment.Center
|
|
390
|
+
) {
|
|
391
|
+
Icon(source = icon)
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// iPhone Duo outer display: back and close sit on the part of the sheet under the side safe area,
|
|
396
|
+
// 12pt from the strip's inner edge like the screen side bar. They follow the header row while the
|
|
397
|
+
// sheet is short and stay below the status bar cluster while it is tall.
|
|
398
|
+
@Composable
|
|
399
|
+
private fun BoxScope.BottomSheetSideColumn(
|
|
400
|
+
sideBar: SideBarLayout,
|
|
401
|
+
parentHeight: Dp,
|
|
402
|
+
backIcon: String,
|
|
403
|
+
onBack: (() -> Unit)?,
|
|
404
|
+
onClose: () -> Unit,
|
|
405
|
+
) {
|
|
406
|
+
val isRight = sideBar.edge == SideBarEdge.RIGHT
|
|
407
|
+
val headerColor = getHeaderColor(HeaderType.None, 1f, null, AppTheme.current.colors.text.default)
|
|
408
|
+
|
|
409
|
+
Box(Modifier.matchParentSize()) {
|
|
410
|
+
Column(
|
|
411
|
+
modifier = Modifier
|
|
412
|
+
.align(if (isRight) AbsoluteAlignment.TopRight else AbsoluteAlignment.TopLeft)
|
|
413
|
+
.width(sideBar.width - Spacing.M)
|
|
414
|
+
.layout { measurable, constraints ->
|
|
415
|
+
val placeable = measurable.measure(constraints.copy(minHeight = 0))
|
|
416
|
+
val sheetHeight = constraints.maxHeight
|
|
417
|
+
// the sheet rests on the bottom of the overlay, which starts at the window top
|
|
418
|
+
val sheetTop = parentHeight.roundToPx() - sheetHeight
|
|
419
|
+
// clear of a camera at the bottom end of the strip when it can
|
|
420
|
+
val lowest = sheetHeight - sideBar.bottom.roundToPx() - placeable.height
|
|
421
|
+
val y = maxOf(sideBar.top.roundToPx() - sheetTop, SIDE_COLUMN_OFFSET.roundToPx())
|
|
422
|
+
.coerceAtMost(lowest)
|
|
423
|
+
.coerceAtLeast(0)
|
|
424
|
+
layout(placeable.width, constraints.constrainHeight(y + placeable.height)) {
|
|
425
|
+
placeable.place(0, y)
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
.absolutePadding(left = if (isRight) Spacing.M else 0.dp, right = if (isRight) 0.dp else Spacing.M),
|
|
429
|
+
horizontalAlignment = if (isRight) AbsoluteAlignment.Left else AbsoluteAlignment.Right,
|
|
430
|
+
verticalArrangement = Arrangement.spacedBy(Spacing.M),
|
|
431
|
+
) {
|
|
432
|
+
if (onBack != null) {
|
|
433
|
+
SideBarButton(
|
|
434
|
+
icon = backIcon,
|
|
435
|
+
headerColor = headerColor,
|
|
436
|
+
automationId = "btn_bottom_sheet_back",
|
|
437
|
+
onClick = onBack,
|
|
438
|
+
)
|
|
439
|
+
}
|
|
440
|
+
SideBarButton(
|
|
441
|
+
icon = "navigation_close",
|
|
442
|
+
headerColor = headerColor,
|
|
443
|
+
automationId = "btn_bottom_sheet_close",
|
|
444
|
+
onClick = onClose,
|
|
445
|
+
)
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
@Composable
|
|
451
|
+
private fun BottomSheetFooter(footerComponent: @Composable () -> Unit, sidePadding: PaddingValues) {
|
|
301
452
|
val shadowBrush = remember {
|
|
302
453
|
Brush.verticalGradient(
|
|
303
454
|
colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.05f))
|
|
@@ -312,6 +463,7 @@ private fun BottomSheetFooter(footerComponent: @Composable () -> Unit) {
|
|
|
312
463
|
.conditional(IsShowBaseLineDebug) {
|
|
313
464
|
border(1.dp, Colors.blue_03)
|
|
314
465
|
}
|
|
466
|
+
.padding(sidePadding)
|
|
315
467
|
.padding(vertical = Spacing.S)
|
|
316
468
|
) {
|
|
317
469
|
footerComponent()
|
|
@@ -330,7 +482,19 @@ private fun BottomSheetFooter(footerComponent: @Composable () -> Unit) {
|
|
|
330
482
|
sealed class BottomHeader {
|
|
331
483
|
data class Title(
|
|
332
484
|
val data: String = "Bottom Sheet Title",
|
|
333
|
-
) : BottomHeader()
|
|
485
|
+
) : BottomHeader() {
|
|
486
|
+
// Outside the primary constructor so code built against older kits keeps Title() and copy().
|
|
487
|
+
// onBack shows a back button: the sheet closes (onDismiss fires), then it runs.
|
|
488
|
+
var onBack: (() -> Unit)? = null
|
|
489
|
+
private set
|
|
490
|
+
var backIcon: String = "arrow-back"
|
|
491
|
+
private set
|
|
492
|
+
|
|
493
|
+
constructor(data: String, onBack: (() -> Unit)?, backIcon: String = "arrow-back") : this(data) {
|
|
494
|
+
this.onBack = onBack
|
|
495
|
+
this.backIcon = backIcon
|
|
496
|
+
}
|
|
497
|
+
}
|
|
334
498
|
|
|
335
499
|
data class Custom(
|
|
336
500
|
val content: @Composable () -> Unit
|