@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
package/CLAUDE.md
CHANGED
|
@@ -65,10 +65,13 @@ Publishing is automated (`publish.sh` + GitLab CI on `[ci build]` / `main`). Do
|
|
|
65
65
|
|
|
66
66
|
- `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.
|
|
67
67
|
- 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` (short side of screen or window > 440, RN `device.ts`) drives popups, modal and bottom sheet.
|
|
68
|
-
- Header height is `headerHeight` (not `AppStatusBar + HEADER_HEIGHT`); the bar row is `headerBarHeight
|
|
69
|
-
- 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.
|
|
70
|
-
-
|
|
71
|
-
-
|
|
68
|
+
- 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, where the header is 76pt and the row starts at 14pt so the title centers on the camera punchout.
|
|
69
|
+
- 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).
|
|
70
|
+
- Reserved regions (iOS 27.1 `reservedRegionsOfKind:`, reached through the ObjC runtime so older SDKs still build): a probe `UIView` (`platform/ReservedRegions.ios.kt` on the Compose root view; `ReservedRegionsProbe` inside SwiftUI `momoScreenLayout()`) re-reads them in `layoutSubviews`, which UIKit re-runs when they change. They push `sideBar.top` below the status cluster as it grows for calls / Live Activities, and `sideBar.bottom` (≥ 24pt) above a camera at the bottom end of the strip.
|
|
71
|
+
- Tab bar on Duo is `bottomtab/DuoTabBar.kt`: a rail in the side strip (`sideBar.bottom` above the window bottom, QR below it) or, without a side bar, a floating bar spanning 6/8 grid columns. Both overlay the content; `duoFloatingTabBarInset` pads tab content, footer and FAB.
|
|
72
|
+
- 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 so the decision follows each screen's toolkit.
|
|
73
|
+
- 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.
|
|
74
|
+
- 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.
|
|
72
75
|
- Overlays on Duo: popups cap at 440pt wide and the room between `duoTopInset` and the bottom inset (body scrolls, actions stay); modal centers inside that room; bottom sheet uses the window height, centers at width ≥ 600 and pads by side insets in landscape.
|
|
73
76
|
- Font scale deliberately uses the short side (`min(w, h)`), unlike RN which reads the window width — landscape must not inflate every font.
|
|
74
77
|
- `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`.
|
package/compose/build.gradle.kts
CHANGED
|
@@ -13,8 +13,10 @@ import androidx.compose.ui.unit.Dp
|
|
|
13
13
|
import androidx.compose.ui.unit.dp
|
|
14
14
|
import vn.momo.kits.const.Spacing
|
|
15
15
|
import vn.momo.kits.navigation.component.HEADER_HEIGHT
|
|
16
|
+
import vn.momo.kits.platform.ReservedRegion
|
|
16
17
|
import vn.momo.kits.platform.getScreenDimensions
|
|
17
18
|
import vn.momo.kits.platform.isPhoneIdiom
|
|
19
|
+
import vn.momo.kits.platform.rememberReservedRegions
|
|
18
20
|
import vn.momo.kits.utils.getAppStatusBarHeight
|
|
19
21
|
import vn.momo.kits.utils.horizontalSafeAreaInsets
|
|
20
22
|
|
|
@@ -26,6 +28,8 @@ data class SideBarLayout(
|
|
|
26
28
|
val width: Dp,
|
|
27
29
|
// Preferred window y of the first control; the column is still kept above footer and bottom bars.
|
|
28
30
|
val top: Dp,
|
|
31
|
+
// Room kept free above the window bottom, clear of a camera at the bottom end of the strip.
|
|
32
|
+
val bottom: Dp,
|
|
29
33
|
)
|
|
30
34
|
|
|
31
35
|
@Immutable
|
|
@@ -65,6 +69,13 @@ internal object DuoMetrics {
|
|
|
65
69
|
val firstItemTopLandscape = 120.dp
|
|
66
70
|
// Window height from which the inner display is in use (outer is 466pt tall in landscape, inner 669pt).
|
|
67
71
|
val innerMinHeight = 600.dp
|
|
72
|
+
// Outer display in portrait: the header is 4pt taller than the iPhone floor and its title
|
|
73
|
+
// lines up with the camera punchout (y 24–66 at the top of the strip).
|
|
74
|
+
val outerHeaderHeight = 76.dp
|
|
75
|
+
val outerCameraCenterY = 45.dp
|
|
76
|
+
// Outer display in landscape has no status bar in the strip: controls keep this far from
|
|
77
|
+
// both ends unless a reserved region (the camera at either end) asks for more.
|
|
78
|
+
val edgeInset = 24.dp
|
|
68
79
|
}
|
|
69
80
|
|
|
70
81
|
// Widest short side of any single-display iPhone.
|
|
@@ -86,7 +97,8 @@ fun getScreenLayout(): ScreenLayout =
|
|
|
86
97
|
internal fun calculateScreenLayout(topInset: Dp, parent: ScreenLayout? = null): ScreenLayout {
|
|
87
98
|
val isPhone = isPhoneIdiom()
|
|
88
99
|
if (parent != null) {
|
|
89
|
-
val (headerHeight, headerStatusBarHeight) =
|
|
100
|
+
val (headerHeight, headerStatusBarHeight) =
|
|
101
|
+
headerGeometry(topInset, isPhone, parent.width, parent.height, parent.sideBar != null)
|
|
90
102
|
return parent.copy(headerHeight = headerHeight, headerStatusBarHeight = headerStatusBarHeight)
|
|
91
103
|
}
|
|
92
104
|
|
|
@@ -98,8 +110,9 @@ internal fun calculateScreenLayout(topInset: Dp, parent: ScreenLayout? = null):
|
|
|
98
110
|
val rightPx = insets.getRight(density, layoutDirection)
|
|
99
111
|
val bottomPx = WindowInsets.systemBars.getBottom(density)
|
|
100
112
|
val screen = getScreenDimensions()
|
|
113
|
+
val reservedRegions = rememberReservedRegions()
|
|
101
114
|
|
|
102
|
-
return remember(size, leftPx, rightPx, bottomPx, topInset, screen, density) {
|
|
115
|
+
return remember(size, leftPx, rightPx, bottomPx, topInset, screen, density, reservedRegions) {
|
|
103
116
|
with(density) {
|
|
104
117
|
val width = size.width.toDp()
|
|
105
118
|
val height = size.height.toDp()
|
|
@@ -116,6 +129,7 @@ internal fun calculateScreenLayout(topInset: Dp, parent: ScreenLayout? = null):
|
|
|
116
129
|
minOf(screen.width, screen.height).dp,
|
|
117
130
|
minOf(width, height),
|
|
118
131
|
) > IPHONE_MAX_SHORT_SIDE,
|
|
132
|
+
reservedRegions = reservedRegions,
|
|
119
133
|
)
|
|
120
134
|
}
|
|
121
135
|
}
|
|
@@ -125,10 +139,32 @@ internal fun calculateScreenLayout(topInset: Dp, parent: ScreenLayout? = null):
|
|
|
125
139
|
internal fun ScreenLayout.consumeHorizontalInsets(): ScreenLayout =
|
|
126
140
|
copy(width = contentWidth, insetLeft = 0.dp, insetRight = 0.dp, sideBar = null)
|
|
127
141
|
|
|
128
|
-
private fun headerGeometry(topInset: Dp, isPhone: Boolean): Pair<Dp, Dp> {
|
|
129
|
-
val
|
|
130
|
-
if (!isPhone) return
|
|
131
|
-
|
|
142
|
+
private fun headerGeometry(topInset: Dp, isPhone: Boolean, width: Dp, height: Dp, hasSideBar: Boolean): Pair<Dp, Dp> {
|
|
143
|
+
val headerHeight = topInset + HEADER_HEIGHT.dp
|
|
144
|
+
if (!isPhone) return headerHeight to topInset
|
|
145
|
+
if (hasSideBar && topInset < STATUS_BAR_MIN_INSET && height > width && width < DuoMetrics.innerMinHeight) {
|
|
146
|
+
// outer display: the row starts below the top so the title centers on the camera
|
|
147
|
+
return DuoMetrics.outerHeaderHeight to DuoMetrics.outerCameraCenterY * 2 - DuoMetrics.outerHeaderHeight
|
|
148
|
+
}
|
|
149
|
+
return maxOf(headerHeight, IPHONE_HEADER_MIN_HEIGHT) to if (topInset < STATUS_BAR_MIN_INSET) 0.dp else topInset
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private data class StripOcclusion(val top: Dp, val bottom: Dp)
|
|
153
|
+
|
|
154
|
+
// How far system regions reach into a side strip from its top and from its bottom end
|
|
155
|
+
// (e.g. the camera, which sits at either end depending on the rotation).
|
|
156
|
+
private fun stripOcclusion(regions: List<ReservedRegion>, stripLeft: Dp, stripRight: Dp, windowHeight: Dp): StripOcclusion {
|
|
157
|
+
var top = 0.dp
|
|
158
|
+
var bottom = 0.dp
|
|
159
|
+
regions.forEach { region ->
|
|
160
|
+
if (region.x >= stripRight || region.x + region.width <= stripLeft) return@forEach
|
|
161
|
+
if (region.y + region.height / 2 < windowHeight / 2) {
|
|
162
|
+
top = maxOf(top, region.y + region.height)
|
|
163
|
+
} else {
|
|
164
|
+
bottom = maxOf(bottom, windowHeight - region.y)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return StripOcclusion(top, bottom)
|
|
132
168
|
}
|
|
133
169
|
|
|
134
170
|
// Slab iPhones report equal side insets in landscape; iPhone Duo moves the status bar to one side.
|
|
@@ -148,8 +184,8 @@ private fun screenLayoutOf(
|
|
|
148
184
|
isPhone: Boolean,
|
|
149
185
|
asymmetricInsets: Boolean,
|
|
150
186
|
isDuoDevice: Boolean,
|
|
187
|
+
reservedRegions: List<ReservedRegion>,
|
|
151
188
|
): ScreenLayout {
|
|
152
|
-
val (headerHeight, headerStatusBarHeight) = headerGeometry(topInset, isPhone)
|
|
153
189
|
val isIPhoneDuo = isPhone && isIPhoneDuoWindow(width, height, asymmetricInsets)
|
|
154
190
|
|
|
155
191
|
var sideBar: SideBarLayout? = null
|
|
@@ -159,15 +195,24 @@ private fun screenLayoutOf(
|
|
|
159
195
|
// UIKit reports no usable status bar frame here (a 2pt top strip), so the spot is inferred like RN
|
|
160
196
|
val top = when {
|
|
161
197
|
topInset >= STATUS_BAR_MIN_INSET -> topInset + HEADER_HEIGHT.dp + Spacing.M
|
|
162
|
-
width
|
|
163
|
-
|
|
198
|
+
width <= height -> DuoMetrics.firstItemTop
|
|
199
|
+
height >= DuoMetrics.innerMinHeight -> DuoMetrics.firstItemTopLandscape
|
|
200
|
+
// outer display in landscape: no status bar in the strip
|
|
201
|
+
else -> DuoMetrics.edgeInset
|
|
164
202
|
}
|
|
203
|
+
val edge = if (insetRight >= insetLeft) SideBarEdge.RIGHT else SideBarEdge.LEFT
|
|
204
|
+
val stripLeft = if (edge == SideBarEdge.RIGHT) width - sideInset else 0.dp
|
|
205
|
+
// clear of the camera and of the status bar cluster, which grows for calls and Live
|
|
206
|
+
// Activities; region frames already include the system's margin
|
|
207
|
+
val occlusion = stripOcclusion(reservedRegions, stripLeft, stripLeft + sideInset, height)
|
|
165
208
|
sideBar = SideBarLayout(
|
|
166
|
-
edge =
|
|
209
|
+
edge = edge,
|
|
167
210
|
width = sideInset,
|
|
168
|
-
top = top,
|
|
211
|
+
top = maxOf(top, occlusion.top),
|
|
212
|
+
bottom = maxOf(DuoMetrics.edgeInset, occlusion.bottom),
|
|
169
213
|
)
|
|
170
214
|
}
|
|
215
|
+
val (headerHeight, headerStatusBarHeight) = headerGeometry(topInset, isPhone, width, height, sideBar != null)
|
|
171
216
|
|
|
172
217
|
return ScreenLayout(
|
|
173
218
|
width = width,
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
package vn.momo.kits.navigation
|
|
2
2
|
|
|
3
3
|
import androidx.compose.animation.animateColorAsState
|
|
4
|
+
import androidx.compose.animation.core.Animatable
|
|
5
|
+
import androidx.compose.animation.core.AnimationVector1D
|
|
6
|
+
import androidx.compose.animation.core.tween
|
|
4
7
|
import androidx.compose.foundation.ScrollState
|
|
5
8
|
import androidx.compose.foundation.background
|
|
6
9
|
import androidx.compose.foundation.border
|
|
@@ -39,6 +42,7 @@ import androidx.compose.ui.Alignment
|
|
|
39
42
|
import androidx.compose.ui.Modifier
|
|
40
43
|
import androidx.compose.ui.graphics.Brush
|
|
41
44
|
import androidx.compose.ui.graphics.Color
|
|
45
|
+
import androidx.compose.ui.graphics.graphicsLayer
|
|
42
46
|
import androidx.compose.ui.graphics.lerp
|
|
43
47
|
import androidx.compose.ui.layout.onGloballyPositioned
|
|
44
48
|
import androidx.compose.ui.platform.LocalDensity
|
|
@@ -65,7 +69,7 @@ import vn.momo.kits.navigation.component.Header
|
|
|
65
69
|
import vn.momo.kits.navigation.component.HeaderBackground
|
|
66
70
|
import vn.momo.kits.navigation.component.HeaderRight
|
|
67
71
|
import vn.momo.kits.navigation.component.HeaderSideBar
|
|
68
|
-
import vn.momo.kits.navigation.bottomtab.
|
|
72
|
+
import vn.momo.kits.navigation.bottomtab.LocalDuoRailReserve
|
|
69
73
|
import vn.momo.kits.navigation.bottomtab.duoFloatingTabBarInset
|
|
70
74
|
import vn.momo.kits.navigation.component.HeaderType
|
|
71
75
|
import vn.momo.kits.navigation.component.InputSearchType
|
|
@@ -180,6 +184,11 @@ internal fun StackScreen(
|
|
|
180
184
|
else
|
|
181
185
|
(options.scrollData.scrollState as? ScrollState ?: rememberScrollState()).proxyLimitedScrollState(limit, 1)
|
|
182
186
|
val snapState: ScrollableState = lazyListState ?: scrollState
|
|
187
|
+
val headerHide = rememberQuickReturnHide(
|
|
188
|
+
enabled = options.usesDuoQuickReturnHeader(screenLayout),
|
|
189
|
+
scrollState = scrollState,
|
|
190
|
+
heightPx = with(density) { screenLayout.headerHeight.toPx() },
|
|
191
|
+
)
|
|
183
192
|
|
|
184
193
|
val footerHeightPx = remember { mutableIntStateOf(0) }
|
|
185
194
|
val headerRightWidthPx = remember { mutableIntStateOf(0) }
|
|
@@ -219,12 +228,15 @@ internal fun StackScreen(
|
|
|
219
228
|
HeaderBackground()
|
|
220
229
|
}
|
|
221
230
|
|
|
222
|
-
Box(Modifier.zIndex(4f)) {
|
|
231
|
+
Box(Modifier.zIndex(4f).graphicsLayer { translationY = -headerHide.value }) {
|
|
223
232
|
Header(onBackHandler)
|
|
224
233
|
}
|
|
225
234
|
|
|
226
235
|
HeaderSideBar(
|
|
227
|
-
bottomInset = maxOf(
|
|
236
|
+
bottomInset = maxOf(
|
|
237
|
+
bottomBarsHeight(isBottomTabChild) + Spacing.XL,
|
|
238
|
+
if (isBottomTabChild) LocalDuoRailReserve.current else 0.dp,
|
|
239
|
+
),
|
|
228
240
|
modifier = Modifier.zIndex(4f),
|
|
229
241
|
)
|
|
230
242
|
|
|
@@ -636,6 +648,46 @@ internal val LocalOptions = staticCompositionLocalOf<NavigationOptions> { error(
|
|
|
636
648
|
|
|
637
649
|
val StackScreenScrollableState = staticCompositionLocalOf<ScrollableState?> { null }
|
|
638
650
|
|
|
651
|
+
// iPhone Duo side bar: the header leaves with the content and slides back in on a reverse scroll
|
|
652
|
+
// (RN DuoQuickReturnHeader). The side bar is drawn apart from the header, so it stays put.
|
|
653
|
+
@Composable
|
|
654
|
+
private fun rememberQuickReturnHide(
|
|
655
|
+
enabled: Boolean,
|
|
656
|
+
scrollState: ScrollState,
|
|
657
|
+
heightPx: Float,
|
|
658
|
+
): Animatable<Float, AnimationVector1D> {
|
|
659
|
+
val hide = remember { Animatable(0f) }
|
|
660
|
+
LaunchedEffect(enabled, scrollState, heightPx) {
|
|
661
|
+
if (!enabled) {
|
|
662
|
+
hide.snapTo(0f)
|
|
663
|
+
return@LaunchedEffect
|
|
664
|
+
}
|
|
665
|
+
var last = scrollState.value
|
|
666
|
+
snapshotFlow { scrollState.value }.collect { y ->
|
|
667
|
+
val delta = y - last
|
|
668
|
+
last = y
|
|
669
|
+
hide.snapTo(if (y <= 0) 0f else (hide.value + delta).coerceIn(0f, heightPx))
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
// a half-shown header settles fully in or out once the scroll stops
|
|
673
|
+
LaunchedEffect(enabled, scrollState, heightPx) {
|
|
674
|
+
if (!enabled) return@LaunchedEffect
|
|
675
|
+
snapshotFlow { scrollState.isScrollInProgress }
|
|
676
|
+
.filter { !it }
|
|
677
|
+
.collect {
|
|
678
|
+
val value = hide.value
|
|
679
|
+
// within the first header height the header still sits on the content
|
|
680
|
+
if (scrollState.value < heightPx || value <= 0f || value >= heightPx) return@collect
|
|
681
|
+
launch { hide.animateTo(if (value > heightPx / 2) heightPx else 0f, tween(200)) }
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
return hide
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
internal fun NavigationOptions.usesDuoQuickReturnHeader(layout: ScreenLayout): Boolean =
|
|
688
|
+
usesDuoGlassHeader(layout) || (layout.sideBar != null && headerType is HeaderType.Animated &&
|
|
689
|
+
scrollData.scrollable && scrollData.scrollState is ScrollState)
|
|
690
|
+
|
|
639
691
|
// iPhone Duo side bar: content scrolls under a glass header instead of starting below it (RN Screen).
|
|
640
692
|
internal fun NavigationOptions.usesDuoGlassHeader(layout: ScreenLayout): Boolean =
|
|
641
693
|
layout.sideBar != null && headerType is HeaderType.DefaultOrExtended &&
|
|
@@ -45,7 +45,10 @@ internal fun getBottomTabOption(index: Int): NavigationOptions? {
|
|
|
45
45
|
@Composable
|
|
46
46
|
fun BottomTab(
|
|
47
47
|
items: List<BottomTabItem>,
|
|
48
|
-
floatingButton: BottomTabFloatingButton? = null
|
|
48
|
+
floatingButton: BottomTabFloatingButton? = null,
|
|
49
|
+
// iPhone Duo: when the side bar can't hold both bars, which one starts compressed;
|
|
50
|
+
// tapping the compressed one swaps them
|
|
51
|
+
compressionBehavior: DuoBarCompressionBehavior = DuoBarCompressionBehavior.TOOLBAR,
|
|
49
52
|
) {
|
|
50
53
|
val navigation = LocalNavigation.current
|
|
51
54
|
val navigator = LocalNavigator.current
|
|
@@ -81,9 +84,11 @@ fun BottomTab(
|
|
|
81
84
|
}
|
|
82
85
|
|
|
83
86
|
val layout = getScreenLayout()
|
|
84
|
-
val
|
|
85
|
-
|
|
86
|
-
|
|
87
|
+
val hasQr = floatingButton != null
|
|
88
|
+
val compression = rememberDuoBarCompression(compressionBehavior, items.size, hasQr, layout)
|
|
89
|
+
val railReserve = layout.sideBar?.takeIf { layout.isIPhoneDuo }?.let {
|
|
90
|
+
it.bottom + duoRailHeight(items.size, hasQr, compressed = compression.tabBarCompressed) + DUO_GROUP_GAP
|
|
91
|
+
} ?: 0.dp
|
|
87
92
|
val onTabSelected: (Int) -> Unit = {
|
|
88
93
|
val targetRoute = "option$it"
|
|
89
94
|
if (currentRoute != targetRoute) {
|
|
@@ -95,7 +100,10 @@ fun BottomTab(
|
|
|
95
100
|
Box(modifier = Modifier
|
|
96
101
|
.fillMaxSize()
|
|
97
102
|
) {
|
|
98
|
-
CompositionLocalProvider(
|
|
103
|
+
CompositionLocalProvider(
|
|
104
|
+
LocalDuoRailReserve provides railReserve,
|
|
105
|
+
LocalDuoBarCompression provides compression,
|
|
106
|
+
) {
|
|
99
107
|
NavHost(
|
|
100
108
|
navController = navController,
|
|
101
109
|
startDestination = "option0"
|
|
@@ -161,6 +169,7 @@ fun BottomTab(
|
|
|
161
169
|
floatingButton = floatingButton,
|
|
162
170
|
selectedIndex = items.indices.firstOrNull { "option$it" == currentRoute } ?: 0,
|
|
163
171
|
onTabSelected = onTabSelected,
|
|
172
|
+
compression = compression,
|
|
164
173
|
)
|
|
165
174
|
} else {
|
|
166
175
|
Column {
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
package vn.momo.kits.navigation.bottomtab
|
|
2
|
+
|
|
3
|
+
import androidx.compose.runtime.Composable
|
|
4
|
+
import androidx.compose.runtime.Stable
|
|
5
|
+
import androidx.compose.runtime.compositionLocalOf
|
|
6
|
+
import androidx.compose.runtime.getValue
|
|
7
|
+
import androidx.compose.runtime.mutableStateOf
|
|
8
|
+
import androidx.compose.runtime.remember
|
|
9
|
+
import androidx.compose.runtime.setValue
|
|
10
|
+
import androidx.compose.ui.unit.Dp
|
|
11
|
+
import vn.momo.kits.const.Spacing
|
|
12
|
+
import vn.momo.kits.layout.DuoMetrics
|
|
13
|
+
import vn.momo.kits.layout.ScreenLayout
|
|
14
|
+
|
|
15
|
+
// iPhone Duo: which side bar bar starts compressed when the strip cannot hold both whole.
|
|
16
|
+
enum class DuoBarCompressionBehavior { TOOLBAR, TAB_BAR }
|
|
17
|
+
|
|
18
|
+
private enum class DuoBar { TOOLBAR, TAB_BAR }
|
|
19
|
+
|
|
20
|
+
@Stable
|
|
21
|
+
internal class DuoBarCompression(
|
|
22
|
+
val toolbarCompressed: Boolean = false,
|
|
23
|
+
val tabBarCompressed: Boolean = false,
|
|
24
|
+
// the open rail needs the toolbar button slot too: only Back stays
|
|
25
|
+
val toolbarHidden: Boolean = false,
|
|
26
|
+
val expandToolbar: () -> Unit = {},
|
|
27
|
+
val expandTabBar: () -> Unit = {},
|
|
28
|
+
// a rail opened from the compressed state closes once a tab is picked
|
|
29
|
+
val onTabSelected: () -> Unit = {},
|
|
30
|
+
// side bar columns report their full height so the tab bar knows what is left
|
|
31
|
+
val setToolbarHeight: (Dp) -> Unit = {},
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
internal val LocalDuoBarCompression = compositionLocalOf { DuoBarCompression() }
|
|
35
|
+
|
|
36
|
+
// space kept between the top and the bottom groups (HIG Vertical controls)
|
|
37
|
+
internal val DUO_GROUP_GAP = Spacing.M
|
|
38
|
+
// Back, then the right toolkit: help + home capsule
|
|
39
|
+
private val DEFAULT_TOOLBAR_HEIGHT = DuoMetrics.controlSize + Spacing.M + DuoMetrics.itemPitch * 2
|
|
40
|
+
// Back, then the single button the toolbar folds into
|
|
41
|
+
private val COMPRESSED_TOOLBAR_HEIGHT = DuoMetrics.controlSize * 2 + Spacing.M
|
|
42
|
+
// Back alone, while a rail opened from the compressed state takes the toolbar slot
|
|
43
|
+
private val BACK_ONLY_HEIGHT = DuoMetrics.controlSize
|
|
44
|
+
|
|
45
|
+
// Port of RN DuoBarCompression (HIG, Designing for iPhone Duo: Vertical controls):
|
|
46
|
+
// - both fit: both stay whole
|
|
47
|
+
// - one fits whole beside the other compressed: one is compressed, tapping it swaps them
|
|
48
|
+
// - otherwise both rest compressed; tapping one opens it for a moment (the rail closes
|
|
49
|
+
// again once a tab is picked)
|
|
50
|
+
@Composable
|
|
51
|
+
internal fun rememberDuoBarCompression(
|
|
52
|
+
behavior: DuoBarCompressionBehavior,
|
|
53
|
+
tabCount: Int,
|
|
54
|
+
hasQr: Boolean,
|
|
55
|
+
layout: ScreenLayout,
|
|
56
|
+
): DuoBarCompression {
|
|
57
|
+
var toolbarHeight by remember { mutableStateOf(DEFAULT_TOOLBAR_HEIGHT) }
|
|
58
|
+
var expanded by remember(behavior) {
|
|
59
|
+
mutableStateOf(if (behavior == DuoBarCompressionBehavior.TOOLBAR) DuoBar.TAB_BAR else DuoBar.TOOLBAR)
|
|
60
|
+
}
|
|
61
|
+
var opened by remember { mutableStateOf<DuoBar?>(null) }
|
|
62
|
+
val setToolbarHeight = remember { { height: Dp -> toolbarHeight = height } }
|
|
63
|
+
val sideBar = layout.sideBar
|
|
64
|
+
|
|
65
|
+
return remember(sideBar, layout.height, tabCount, hasQr, toolbarHeight, expanded, opened) {
|
|
66
|
+
if (sideBar == null) return@remember DuoBarCompression()
|
|
67
|
+
val room = layout.height - sideBar.bottom - sideBar.top - DUO_GROUP_GAP
|
|
68
|
+
val tabBar = duoRailHeight(tabCount, hasQr)
|
|
69
|
+
val compressedTabBar = duoRailHeight(1, hasQr, compressed = true)
|
|
70
|
+
fun fits(toolbar: Dp, tabs: Dp) = toolbar + tabs <= room
|
|
71
|
+
|
|
72
|
+
when {
|
|
73
|
+
fits(toolbarHeight, tabBar) -> DuoBarCompression(setToolbarHeight = setToolbarHeight)
|
|
74
|
+
fits(COMPRESSED_TOOLBAR_HEIGHT, tabBar) && fits(toolbarHeight, compressedTabBar) -> DuoBarCompression(
|
|
75
|
+
toolbarCompressed = expanded == DuoBar.TAB_BAR,
|
|
76
|
+
tabBarCompressed = expanded == DuoBar.TOOLBAR,
|
|
77
|
+
expandToolbar = { expanded = DuoBar.TOOLBAR },
|
|
78
|
+
expandTabBar = { expanded = DuoBar.TAB_BAR },
|
|
79
|
+
setToolbarHeight = setToolbarHeight,
|
|
80
|
+
)
|
|
81
|
+
opened == DuoBar.TAB_BAR && fits(BACK_ONLY_HEIGHT, tabBar) -> DuoBarCompression(
|
|
82
|
+
toolbarCompressed = true,
|
|
83
|
+
toolbarHidden = true,
|
|
84
|
+
expandToolbar = { opened = DuoBar.TOOLBAR },
|
|
85
|
+
expandTabBar = { opened = DuoBar.TAB_BAR },
|
|
86
|
+
onTabSelected = { opened = null },
|
|
87
|
+
setToolbarHeight = setToolbarHeight,
|
|
88
|
+
)
|
|
89
|
+
else -> DuoBarCompression(
|
|
90
|
+
toolbarCompressed = opened != DuoBar.TOOLBAR,
|
|
91
|
+
tabBarCompressed = true,
|
|
92
|
+
expandToolbar = { opened = DuoBar.TOOLBAR },
|
|
93
|
+
expandTabBar = { opened = DuoBar.TAB_BAR },
|
|
94
|
+
setToolbarHeight = setToolbarHeight,
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -55,7 +55,7 @@ private val ITEM_SIZE = DuoMetrics.controlSize
|
|
|
55
55
|
private val ICON_SIZE = 28.dp
|
|
56
56
|
private val QR_ICON_SIZE = 24.dp
|
|
57
57
|
private val RAIL_PADDING = 6.dp
|
|
58
|
-
private val RAIL_GAP = Spacing.
|
|
58
|
+
private val RAIL_GAP = Spacing.XXS
|
|
59
59
|
// selection bubble overflows the 48 rail cell by 5 above and below
|
|
60
60
|
private val RAIL_BUBBLE_WIDTH = 44.dp
|
|
61
61
|
private val RAIL_BUBBLE_HEIGHT = 58.dp
|
|
@@ -68,12 +68,15 @@ private const val GRID_SPAN = 6
|
|
|
68
68
|
// RN reanimated spring { damping: 18, stiffness: 220, mass: 0.8 }
|
|
69
69
|
private val BUBBLE_SPRING = spring<Dp>(dampingRatio = 0.68f, stiffness = 275f, visibilityThreshold = Dp(0.5f))
|
|
70
70
|
|
|
71
|
-
// Space from the window bottom the rail stack
|
|
72
|
-
internal val
|
|
71
|
+
// Space from the window bottom the rail stack and its gap take, so the header side bar stays above it.
|
|
72
|
+
internal val LocalDuoRailReserve = compositionLocalOf { 0.dp }
|
|
73
73
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
74
|
+
// Rail plus the QR button under it; compressed, the rail is a single button.
|
|
75
|
+
internal fun duoRailHeight(itemCount: Int, hasQr: Boolean, compressed: Boolean = false): Dp {
|
|
76
|
+
val rail = if (compressed) ITEM_SIZE
|
|
77
|
+
else RAIL_PADDING * 2 + ITEM_SIZE * itemCount + RAIL_GAP * (itemCount - 1).coerceAtLeast(0)
|
|
78
|
+
return rail + if (hasQr) Spacing.M + ITEM_SIZE else 0.dp
|
|
79
|
+
}
|
|
77
80
|
|
|
78
81
|
// Space tab content leaves under the floating bar; 0 when the rail sits in the side strip or off Duo.
|
|
79
82
|
internal fun duoFloatingTabBarInset(layout: ScreenLayout): Dp =
|
|
@@ -87,14 +90,18 @@ internal fun BoxScope.DuoTabBar(
|
|
|
87
90
|
floatingButton: BottomTabFloatingButton?,
|
|
88
91
|
selectedIndex: Int,
|
|
89
92
|
onTabSelected: (Int) -> Unit,
|
|
93
|
+
compression: DuoBarCompression = DuoBarCompression(),
|
|
90
94
|
) {
|
|
91
95
|
if (layout.sideBar != null) {
|
|
92
|
-
DuoRailTabBar(layout, items, floatingButton, selectedIndex, onTabSelected)
|
|
96
|
+
DuoRailTabBar(layout, items, floatingButton, selectedIndex, onTabSelected, compression)
|
|
93
97
|
} else {
|
|
94
98
|
DuoFloatingTabBar(layout, items, floatingButton, selectedIndex, onTabSelected)
|
|
95
99
|
}
|
|
96
100
|
}
|
|
97
101
|
|
|
102
|
+
// Vertical bar anchored to the bottom of the side bar, QR button below it (Figma MT1-C/E). When the
|
|
103
|
+
// side bar is short it can shrink to one button showing the current tab (HIG "Tab bar compressed");
|
|
104
|
+
// tapping it expands the rail and compresses the toolbar.
|
|
98
105
|
@Composable
|
|
99
106
|
private fun BoxScope.DuoRailTabBar(
|
|
100
107
|
layout: ScreenLayout,
|
|
@@ -102,53 +109,36 @@ private fun BoxScope.DuoRailTabBar(
|
|
|
102
109
|
floatingButton: BottomTabFloatingButton?,
|
|
103
110
|
selectedIndex: Int,
|
|
104
111
|
onTabSelected: (Int) -> Unit,
|
|
112
|
+
compression: DuoBarCompression,
|
|
105
113
|
) {
|
|
106
114
|
val sideBar = layout.sideBar ?: return
|
|
107
115
|
val isRight = sideBar.edge == SideBarEdge.RIGHT
|
|
108
|
-
val bubbleOffset by animateDpAsState(
|
|
109
|
-
targetValue = (ITEM_SIZE + RAIL_GAP) * selectedIndex.coerceAtLeast(0),
|
|
110
|
-
animationSpec = BUBBLE_SPRING,
|
|
111
|
-
label = "DuoRailBubble",
|
|
112
|
-
)
|
|
113
116
|
|
|
114
117
|
Column(
|
|
115
118
|
modifier = Modifier
|
|
116
119
|
.align(if (isRight) AbsoluteAlignment.BottomRight else AbsoluteAlignment.BottomLeft)
|
|
117
120
|
.width(sideBar.width)
|
|
118
|
-
.padding(bottom =
|
|
121
|
+
.padding(bottom = sideBar.bottom)
|
|
119
122
|
.absolutePadding(left = if (isRight) Spacing.M else 0.dp, right = if (isRight) 0.dp else Spacing.M),
|
|
120
123
|
horizontalAlignment = if (isRight) AbsoluteAlignment.Left else AbsoluteAlignment.Right,
|
|
121
124
|
verticalArrangement = Arrangement.spacedBy(Spacing.M),
|
|
122
125
|
) {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
.width(ITEM_SIZE)
|
|
126
|
-
.tabBarMaterial(RoundedCornerShape(Radius.XL))
|
|
127
|
-
.setAutomationId("bottom_tab_bar")
|
|
128
|
-
) {
|
|
126
|
+
val current = items.getOrNull(selectedIndex)
|
|
127
|
+
if (compression.tabBarCompressed && current != null) {
|
|
129
128
|
Box(
|
|
130
|
-
Modifier
|
|
131
|
-
.
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
)
|
|
135
|
-
|
|
136
|
-
.background(AppTheme.current.colors.background.selected, CircleShape)
|
|
137
|
-
)
|
|
138
|
-
Column(
|
|
139
|
-
modifier = Modifier.padding(vertical = RAIL_PADDING),
|
|
140
|
-
verticalArrangement = Arrangement.spacedBy(RAIL_GAP),
|
|
129
|
+
modifier = Modifier
|
|
130
|
+
.size(ITEM_SIZE)
|
|
131
|
+
.tabBarMaterial(CircleShape)
|
|
132
|
+
.noFeedbackClickable(onClick = compression.expandTabBar)
|
|
133
|
+
.setAutomationId("bottom_tab_bar"),
|
|
134
|
+
contentAlignment = Alignment.Center,
|
|
141
135
|
) {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
) {
|
|
149
|
-
DuoTabIcon(item, selected = index == selectedIndex)
|
|
150
|
-
}
|
|
151
|
-
}
|
|
136
|
+
DuoTabIcon(current, selected = true)
|
|
137
|
+
}
|
|
138
|
+
} else {
|
|
139
|
+
Rail(items, selectedIndex) {
|
|
140
|
+
onTabSelected(it)
|
|
141
|
+
compression.onTabSelected()
|
|
152
142
|
}
|
|
153
143
|
}
|
|
154
144
|
if (floatingButton != null) {
|
|
@@ -157,6 +147,47 @@ private fun BoxScope.DuoRailTabBar(
|
|
|
157
147
|
}
|
|
158
148
|
}
|
|
159
149
|
|
|
150
|
+
// Glass column of tab items with the sliding selection bubble.
|
|
151
|
+
@Composable
|
|
152
|
+
private fun Rail(items: List<BottomTabItem>, selectedIndex: Int, onItemClick: (Int) -> Unit) {
|
|
153
|
+
val bubbleOffset by animateDpAsState(
|
|
154
|
+
targetValue = (ITEM_SIZE + RAIL_GAP) * selectedIndex.coerceAtLeast(0),
|
|
155
|
+
animationSpec = BUBBLE_SPRING,
|
|
156
|
+
label = "DuoRailBubble",
|
|
157
|
+
)
|
|
158
|
+
Box(
|
|
159
|
+
Modifier
|
|
160
|
+
.width(ITEM_SIZE)
|
|
161
|
+
.tabBarMaterial(RoundedCornerShape(Radius.XL))
|
|
162
|
+
.setAutomationId("bottom_tab_bar")
|
|
163
|
+
) {
|
|
164
|
+
Box(
|
|
165
|
+
Modifier
|
|
166
|
+
.offset(
|
|
167
|
+
x = (ITEM_SIZE - RAIL_BUBBLE_WIDTH) / 2,
|
|
168
|
+
y = RAIL_PADDING - (RAIL_BUBBLE_HEIGHT - ITEM_SIZE) / 2 + bubbleOffset,
|
|
169
|
+
)
|
|
170
|
+
.size(RAIL_BUBBLE_WIDTH, RAIL_BUBBLE_HEIGHT)
|
|
171
|
+
.background(AppTheme.current.colors.background.selected, CircleShape)
|
|
172
|
+
)
|
|
173
|
+
Column(
|
|
174
|
+
modifier = Modifier.padding(vertical = RAIL_PADDING),
|
|
175
|
+
verticalArrangement = Arrangement.spacedBy(RAIL_GAP),
|
|
176
|
+
) {
|
|
177
|
+
items.forEachIndexed { index, item ->
|
|
178
|
+
Box(
|
|
179
|
+
modifier = Modifier
|
|
180
|
+
.size(ITEM_SIZE)
|
|
181
|
+
.noFeedbackClickable { onItemClick(index) },
|
|
182
|
+
contentAlignment = Alignment.Center,
|
|
183
|
+
) {
|
|
184
|
+
DuoTabIcon(item, selected = index == selectedIndex)
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
160
191
|
@Composable
|
|
161
192
|
private fun BoxScope.DuoFloatingTabBar(
|
|
162
193
|
layout: ScreenLayout,
|