@momo-kits/native-kits 0.168.1-beta.24-debug → 0.168.1-beta.25-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 +1 -0
- package/compose/build.gradle.kts +1 -1
- package/compose/compose.podspec +1 -1
- package/compose/src/androidMain/kotlin/vn/momo/kits/platform/ReduceMotion.android.kt +17 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTab.kt +4 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTabBar.kt +34 -14
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/DuoLiquidSelection.kt +380 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/DuoTabBar.kt +149 -90
- package/compose/src/commonMain/kotlin/vn/momo/kits/platform/ReduceMotion.kt +7 -0
- package/compose/src/iosMain/kotlin/vn/momo/kits/platform/ReduceMotion.ios.kt +48 -0
- package/gradle.properties +1 -1
- package/ios/Navigation/BottomTab/BottomTab.swift +16 -3
- package/ios/Navigation/BottomTab/BottomTabBar.swift +47 -29
- package/ios/Navigation/BottomTab/DuoLiquidSelection.swift +364 -0
- package/ios/Navigation/BottomTab/DuoTabBar.swift +156 -69
- package/ios/native-kits.podspec +1 -1
- package/ios-demo/MoMoUIKitsDemo.podspec +1 -1
- package/package.json +1 -1
- package/local.properties +0 -8
package/CLAUDE.md
CHANGED
|
@@ -77,6 +77,7 @@ Publishing is automated (`publish.sh` + GitLab CI on `[ci build]` / `main`). Do
|
|
|
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).
|
|
78
78
|
- 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.
|
|
79
79
|
- 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.
|
|
80
|
+
- Duo tab selection motion (`bottomtab/DuoLiquidSelection.kt` / `ios/Navigation/BottomTab/DuoLiquidSelection.swift`, port of RN `useLiquidSelection.ts`, momo-kits !682): press lifts the lens, a drag past 10pt takes over (Compose: the bar's `pointerInput` consumes and so cancels the tab's clickable; SwiftUI: one `DragGesture` over the bar, tabs are not `Button`s), long press calls `BottomTab(onTabLongPress)` and release still selects, Reduce Motion (`platform/ReduceMotion`) falls back to a plain slide. SwiftUI integrates the lens spring on a display link because the stretch and the tabs' active tint read it every frame. Constants mirror RN — change both together.
|
|
80
81
|
- 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
82
|
- 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
83
|
- 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.
|
package/compose/build.gradle.kts
CHANGED
package/compose/compose.podspec
CHANGED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
package vn.momo.kits.platform
|
|
2
|
+
|
|
3
|
+
import android.provider.Settings
|
|
4
|
+
import androidx.compose.runtime.Composable
|
|
5
|
+
import androidx.compose.runtime.remember
|
|
6
|
+
import androidx.compose.ui.platform.LocalConfiguration
|
|
7
|
+
import androidx.compose.ui.platform.LocalContext
|
|
8
|
+
|
|
9
|
+
@Composable
|
|
10
|
+
actual fun rememberReduceMotion(): Boolean {
|
|
11
|
+
val context = LocalContext.current
|
|
12
|
+
// re-read on configuration changes; the setting has no observer of its own
|
|
13
|
+
val configuration = LocalConfiguration.current
|
|
14
|
+
return remember(context, configuration) {
|
|
15
|
+
Settings.Global.getFloat(context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) == 0f
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -72,6 +72,8 @@ fun BottomTab(
|
|
|
72
72
|
initialRouteName: String? = null,
|
|
73
73
|
onTabPress: ((index: Int, switchTab: () -> Unit) -> Unit)? = null,
|
|
74
74
|
backToInitialTab: Boolean = true,
|
|
75
|
+
// RN tabLongPress; on iPhone Duo the held tab is still selected on release
|
|
76
|
+
onTabLongPress: ((index: Int) -> Unit)? = null,
|
|
75
77
|
) {
|
|
76
78
|
val navigation = LocalNavigation.current
|
|
77
79
|
val navigator = LocalNavigator.current
|
|
@@ -195,6 +197,7 @@ fun BottomTab(
|
|
|
195
197
|
selectedIndex = selectedIndex,
|
|
196
198
|
onTabSelected = onTabSelected,
|
|
197
199
|
compression = compression,
|
|
200
|
+
onTabLongPress = onTabLongPress,
|
|
198
201
|
)
|
|
199
202
|
} else {
|
|
200
203
|
Column {
|
|
@@ -203,6 +206,7 @@ fun BottomTab(
|
|
|
203
206
|
floatingButton = floatingButton,
|
|
204
207
|
selectedIndex = selectedIndex,
|
|
205
208
|
onTabSelected = onTabSelected,
|
|
209
|
+
onTabLongPress = onTabLongPress,
|
|
206
210
|
)
|
|
207
211
|
Spacer(modifier = Modifier.fillMaxWidth().height(AppNavigationBar.current + Spacing.S).background(AppTheme.current.colors.background.surface))
|
|
208
212
|
}
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
package vn.momo.kits.navigation.bottomtab
|
|
2
2
|
|
|
3
3
|
import androidx.compose.animation.core.Animatable
|
|
4
|
+
import androidx.compose.animation.core.animateFloatAsState
|
|
4
5
|
import androidx.compose.foundation.background
|
|
5
6
|
import androidx.compose.foundation.border
|
|
7
|
+
import androidx.compose.foundation.combinedClickable
|
|
8
|
+
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
9
|
+
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
|
6
10
|
import androidx.compose.foundation.layout.*
|
|
7
11
|
import androidx.compose.foundation.shape.CircleShape
|
|
8
12
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
@@ -18,6 +22,8 @@ import androidx.compose.ui.Modifier
|
|
|
18
22
|
import androidx.compose.ui.draw.clip
|
|
19
23
|
import androidx.compose.ui.graphics.Brush
|
|
20
24
|
import androidx.compose.ui.graphics.Color
|
|
25
|
+
import androidx.compose.ui.graphics.graphicsLayer
|
|
26
|
+
import androidx.compose.ui.semantics.Role
|
|
21
27
|
import androidx.compose.ui.text.style.TextAlign
|
|
22
28
|
import androidx.compose.ui.text.style.TextOverflow
|
|
23
29
|
import androidx.compose.ui.unit.Dp
|
|
@@ -41,6 +47,7 @@ internal fun BottomTabBar(
|
|
|
41
47
|
floatingButton: BottomTabFloatingButton? = null,
|
|
42
48
|
selectedIndex: Int,
|
|
43
49
|
onTabSelected: (Int) -> Unit,
|
|
50
|
+
onTabLongPress: ((Int) -> Unit)? = null,
|
|
44
51
|
) {
|
|
45
52
|
|
|
46
53
|
val layout = getScreenLayout()
|
|
@@ -73,11 +80,11 @@ internal fun BottomTabBar(
|
|
|
73
80
|
horizontalArrangement = Arrangement.SpaceAround,
|
|
74
81
|
) {
|
|
75
82
|
if (floating == null) {
|
|
76
|
-
RenderTabBarItem(items, 0, items.size, selectedIndex, onTabSelected)
|
|
83
|
+
RenderTabBarItem(items, 0, items.size, selectedIndex, onTabSelected, onTabLongPress)
|
|
77
84
|
} else {
|
|
78
|
-
RenderTabBarItem(items, 0, mid, selectedIndex, onTabSelected)
|
|
85
|
+
RenderTabBarItem(items, 0, mid, selectedIndex, onTabSelected, onTabLongPress)
|
|
79
86
|
Spacer(modifier = Modifier.width(slotWidth))
|
|
80
|
-
RenderTabBarItem(items, mid, items.size, selectedIndex, onTabSelected)
|
|
87
|
+
RenderTabBarItem(items, mid, items.size, selectedIndex, onTabSelected, onTabLongPress)
|
|
81
88
|
}
|
|
82
89
|
}
|
|
83
90
|
|
|
@@ -115,37 +122,48 @@ internal fun RowScope.RenderTabBarItem(
|
|
|
115
122
|
fromIndex: Int,
|
|
116
123
|
toIndex: Int,
|
|
117
124
|
selectedIndex: Int,
|
|
118
|
-
onTabSelected: (Int) -> Unit
|
|
125
|
+
onTabSelected: (Int) -> Unit,
|
|
126
|
+
onTabLongPress: ((Int) -> Unit)? = null,
|
|
119
127
|
){
|
|
120
128
|
for (index in fromIndex until toIndex) {
|
|
121
129
|
Box(modifier = Modifier.weight(1f)) {
|
|
122
130
|
TabBarItem(
|
|
123
131
|
item = items[index],
|
|
124
132
|
selected = index == selectedIndex,
|
|
125
|
-
onClick = { onTabSelected(index) }
|
|
133
|
+
onClick = { onTabSelected(index) },
|
|
134
|
+
onLongClick = onTabLongPress?.let { { it(index) } },
|
|
126
135
|
)
|
|
127
136
|
}
|
|
128
137
|
}
|
|
129
138
|
}
|
|
130
139
|
|
|
131
140
|
@Composable
|
|
132
|
-
internal fun TabBarItem(item: BottomTabItem, selected: Boolean, onClick: () -> Unit) {
|
|
141
|
+
internal fun TabBarItem(item: BottomTabItem, selected: Boolean, onClick: () -> Unit, onLongClick: (() -> Unit)? = null) {
|
|
133
142
|
// RN parity: "0" hides the badge, "" renders a dot.
|
|
134
143
|
val badgeLabel = item.badgeLabel?.takeUnless { it.toIntOrNull() == 0 }
|
|
135
|
-
val isNumber = badgeLabel?.toIntOrNull() != null
|
|
136
144
|
val showDot = badgeLabel?.isEmpty() ?: item.showDot
|
|
145
|
+
val interactionSource = remember { MutableInteractionSource() }
|
|
146
|
+
val pressed by interactionSource.collectIsPressedAsState()
|
|
147
|
+
val alpha by animateFloatAsState(if (pressed) 0.2f else 1f, label = "TabBarItemOpacity")
|
|
137
148
|
|
|
138
|
-
|
|
149
|
+
BoxWithConstraints(modifier = Modifier
|
|
139
150
|
.fillMaxSize()
|
|
140
151
|
.padding(horizontal = Spacing.XXS)
|
|
141
152
|
.conditional(IsShowBaseLineDebug) {
|
|
142
153
|
border(1.dp, Colors.blue_03)
|
|
143
154
|
}
|
|
144
|
-
.
|
|
145
|
-
|
|
146
|
-
|
|
155
|
+
.graphicsLayer { this.alpha = alpha }
|
|
156
|
+
.combinedClickable(
|
|
157
|
+
interactionSource = interactionSource,
|
|
158
|
+
indication = null,
|
|
159
|
+
role = Role.Tab,
|
|
160
|
+
onLongClick = onLongClick,
|
|
161
|
+
onClick = onClick,
|
|
162
|
+
),
|
|
147
163
|
contentAlignment = Alignment.BottomStart
|
|
148
164
|
){
|
|
165
|
+
// from the icon's center to the cell's end edge
|
|
166
|
+
val badgeMaxWidth = (maxWidth / 2 - Spacing.XXS).coerceAtLeast(0.dp)
|
|
149
167
|
Column(
|
|
150
168
|
modifier = Modifier
|
|
151
169
|
.fillMaxSize()
|
|
@@ -158,11 +176,13 @@ internal fun TabBarItem(item: BottomTabItem, selected: Boolean, onClick: () -> U
|
|
|
158
176
|
source = item.icon,
|
|
159
177
|
size = 28.dp,
|
|
160
178
|
color = if (selected) AppTheme.current.colors.primary else AppTheme.current.colors.text.hint)
|
|
161
|
-
val badgeAnchor = Modifier.matchParentSize().wrapContentSize(Alignment.TopEnd, unbounded = true)
|
|
162
179
|
if (showDot) {
|
|
163
|
-
|
|
180
|
+
val dotAnchor = Modifier.matchParentSize().wrapContentSize(Alignment.TopEnd, unbounded = true)
|
|
181
|
+
BadgeDot(size = DotSize.Small, modifier = dotAnchor.offset(x = 4.dp, y = (-4).dp))
|
|
164
182
|
} else if (!badgeLabel.isNullOrEmpty()) {
|
|
165
|
-
|
|
183
|
+
// starts at the icon's center and grows toward the cell's end edge, ellipsized past it
|
|
184
|
+
val badgeAnchor = Modifier.matchParentSize().wrapContentSize(Alignment.TopStart, unbounded = true)
|
|
185
|
+
Badge(badgeLabel, modifier = badgeAnchor.offset(x = 14.dp, y = (-8).dp).widthIn(max = badgeMaxWidth))
|
|
166
186
|
}
|
|
167
187
|
}
|
|
168
188
|
Text(
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
package vn.momo.kits.navigation.bottomtab
|
|
2
|
+
|
|
3
|
+
import androidx.compose.animation.core.Animatable
|
|
4
|
+
import androidx.compose.animation.core.SpringSpec
|
|
5
|
+
import androidx.compose.animation.core.spring
|
|
6
|
+
import androidx.compose.animation.core.tween
|
|
7
|
+
import androidx.compose.foundation.gestures.awaitEachGesture
|
|
8
|
+
import androidx.compose.foundation.gestures.awaitFirstDown
|
|
9
|
+
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
10
|
+
import androidx.compose.foundation.interaction.PressInteraction
|
|
11
|
+
import androidx.compose.runtime.Composable
|
|
12
|
+
import androidx.compose.runtime.LaunchedEffect
|
|
13
|
+
import androidx.compose.runtime.Stable
|
|
14
|
+
import androidx.compose.runtime.getValue
|
|
15
|
+
import androidx.compose.runtime.mutableFloatStateOf
|
|
16
|
+
import androidx.compose.runtime.mutableIntStateOf
|
|
17
|
+
import androidx.compose.runtime.mutableStateOf
|
|
18
|
+
import androidx.compose.runtime.remember
|
|
19
|
+
import androidx.compose.runtime.rememberCoroutineScope
|
|
20
|
+
import androidx.compose.runtime.rememberUpdatedState
|
|
21
|
+
import androidx.compose.runtime.setValue
|
|
22
|
+
import androidx.compose.runtime.snapshotFlow
|
|
23
|
+
import androidx.compose.ui.Modifier
|
|
24
|
+
import androidx.compose.ui.geometry.Offset
|
|
25
|
+
import androidx.compose.ui.graphics.graphicsLayer
|
|
26
|
+
import androidx.compose.ui.input.pointer.pointerInput
|
|
27
|
+
import androidx.compose.ui.input.pointer.util.VelocityTracker
|
|
28
|
+
import androidx.compose.ui.platform.LocalDensity
|
|
29
|
+
import androidx.compose.ui.unit.Dp
|
|
30
|
+
import androidx.compose.ui.unit.dp
|
|
31
|
+
import kotlinx.coroutines.CoroutineScope
|
|
32
|
+
import kotlinx.coroutines.Job
|
|
33
|
+
import kotlinx.coroutines.delay
|
|
34
|
+
import kotlinx.coroutines.launch
|
|
35
|
+
import vn.momo.kits.platform.rememberReduceMotion
|
|
36
|
+
import kotlin.math.abs
|
|
37
|
+
import kotlin.math.min
|
|
38
|
+
import kotlin.math.roundToInt
|
|
39
|
+
|
|
40
|
+
// iOS 26 tab bar selection motion without the glass material, port of RN useLiquidSelection.ts.
|
|
41
|
+
// Constants mirror RN; springs are RN (mass, stiffness, damping) converted to mass 1.
|
|
42
|
+
private val SPRING = spring<Float>(dampingRatio = 0.68f, stiffness = 275f)
|
|
43
|
+
// the lens trails the finger a little while dragged (RN damping 24, stiffness 600, mass 0.6)
|
|
44
|
+
private val DRAG_SPRING = spring<Float>(dampingRatio = 0.63f, stiffness = 1000f)
|
|
45
|
+
private val REDUCED_TWEEN = tween<Float>(durationMillis = 200)
|
|
46
|
+
// while pressed the selection bubble grows into a lens past the bar's edges (80x54 to about 96x70)
|
|
47
|
+
private const val LENS_SCALE_X = 1.2f
|
|
48
|
+
private const val LENS_SCALE_Y = 1.3f
|
|
49
|
+
// a moving lens stretches along its path and thins across it, eased per frame
|
|
50
|
+
private const val STRETCH_PER_POINT = 0.012f
|
|
51
|
+
private const val MAX_STRETCH = 0.25f
|
|
52
|
+
private const val STRETCH_EASING = 0.25f
|
|
53
|
+
private const val REST_STRETCH = 0.002f
|
|
54
|
+
// a tap is shorter than the lift: the lens finishes rising, then settles, one smooth pulse
|
|
55
|
+
private const val TAP_LIFT_MILLIS = 120
|
|
56
|
+
private const val BAR_SWELL = 0.04f
|
|
57
|
+
private const val ICON_POP = 1.15f
|
|
58
|
+
private const val ICON_POP_MILLIS = 120
|
|
59
|
+
// below it the tab's clickable handles the press as a tap
|
|
60
|
+
private val DRAG_SLOP = 10.dp
|
|
61
|
+
// UIScrollView rubber band constant
|
|
62
|
+
private const val RUBBER_BAND = 0.55f
|
|
63
|
+
// a cancelled press or a vetoed switch: the lens returns to the selected tab after this
|
|
64
|
+
private const val RESYNC_DELAY_MILLIS = 100L
|
|
65
|
+
|
|
66
|
+
internal enum class LiquidAxis { X, Y }
|
|
67
|
+
|
|
68
|
+
// resistance past the first and last tab, the further the finger goes the less the lens follows
|
|
69
|
+
private fun rubberBand(value: Float, max: Float, dimension: Float): Float {
|
|
70
|
+
fun band(overshoot: Float) = (1 - 1 / (overshoot * RUBBER_BAND / dimension + 1)) * dimension
|
|
71
|
+
return when {
|
|
72
|
+
value < 0 -> -band(-value)
|
|
73
|
+
value > max -> max + band(value - max)
|
|
74
|
+
else -> value
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
@Stable
|
|
79
|
+
internal class DuoLiquidSelection(
|
|
80
|
+
private val scope: CoroutineScope,
|
|
81
|
+
private val axis: LiquidAxis,
|
|
82
|
+
private val density: Float,
|
|
83
|
+
) {
|
|
84
|
+
var count = 0
|
|
85
|
+
// px between two neighbouring tab centers / to the first tab's center inside the drag modifier
|
|
86
|
+
var pitch = 0f
|
|
87
|
+
var firstCenter = 0f
|
|
88
|
+
var reduceMotion = false
|
|
89
|
+
var onSelect: (Int) -> Unit = {}
|
|
90
|
+
var onTick: () -> Unit = {}
|
|
91
|
+
|
|
92
|
+
val offset = Animatable(0f)
|
|
93
|
+
val lift = Animatable(0f)
|
|
94
|
+
var stretch by mutableFloatStateOf(0f)
|
|
95
|
+
private set
|
|
96
|
+
var selectedIndex by mutableIntStateOf(0)
|
|
97
|
+
private set
|
|
98
|
+
|
|
99
|
+
// where the lens is heading, so a recomposition doesn't restart a spring already going there
|
|
100
|
+
private var destination = 0f
|
|
101
|
+
private var dragging = false
|
|
102
|
+
private var hovered = 0
|
|
103
|
+
private var measured = false
|
|
104
|
+
private var resyncJob: Job? = null
|
|
105
|
+
|
|
106
|
+
private fun move(target: Float, velocity: Float? = null, spec: SpringSpec<Float> = SPRING) {
|
|
107
|
+
if (destination == target && velocity == null) return
|
|
108
|
+
destination = target
|
|
109
|
+
scope.launch {
|
|
110
|
+
if (reduceMotion) offset.animateTo(target, REDUCED_TWEEN)
|
|
111
|
+
else offset.animateTo(target, spec, initialVelocity = velocity ?: offset.velocity)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private fun animateLift(target: Float) {
|
|
116
|
+
scope.launch { if (reduceMotion) lift.snapTo(0f) else lift.animateTo(target, SPRING) }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
suspend fun sync(index: Int) {
|
|
120
|
+
selectedIndex = index
|
|
121
|
+
val target = index * pitch
|
|
122
|
+
// snap on first layout so the lens doesn't slide in from the leading edge
|
|
123
|
+
if (measured) {
|
|
124
|
+
move(target)
|
|
125
|
+
} else {
|
|
126
|
+
destination = target
|
|
127
|
+
offset.snapTo(target)
|
|
128
|
+
}
|
|
129
|
+
measured = pitch > 0
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
suspend fun trackStretch() {
|
|
133
|
+
var previous = offset.value
|
|
134
|
+
snapshotFlow { offset.value }.collect { current ->
|
|
135
|
+
if (reduceMotion) {
|
|
136
|
+
stretch = 0f
|
|
137
|
+
} else {
|
|
138
|
+
val target = min(abs(current - previous) / density * STRETCH_PER_POINT, MAX_STRETCH)
|
|
139
|
+
// eased both ways: a slow frame (the spring turning at its overshoot) must not snap the shape
|
|
140
|
+
val next = stretch + (target - stretch) * STRETCH_EASING
|
|
141
|
+
stretch = if (next < REST_STRETCH) 0f else next
|
|
142
|
+
}
|
|
143
|
+
previous = current
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// returns the lens to the selected tab unless a switch to where it is heading happened
|
|
148
|
+
private fun resync() {
|
|
149
|
+
resyncJob?.cancel()
|
|
150
|
+
resyncJob = scope.launch {
|
|
151
|
+
delay(RESYNC_DELAY_MILLIS)
|
|
152
|
+
val target = selectedIndex * pitch
|
|
153
|
+
if (!dragging && destination != target) move(target)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
fun pressIn(index: Int) {
|
|
158
|
+
resyncJob?.cancel()
|
|
159
|
+
// the lens heads to the pressed tab, not only the selected one
|
|
160
|
+
move(index * pitch)
|
|
161
|
+
animateLift(1f)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// false when a drag took the press over
|
|
165
|
+
fun pressOut(): Boolean {
|
|
166
|
+
if (dragging) return false
|
|
167
|
+
if (!reduceMotion) {
|
|
168
|
+
scope.launch {
|
|
169
|
+
lift.animateTo(1f, tween(TAP_LIFT_MILLIS))
|
|
170
|
+
lift.animateTo(0f, SPRING)
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
// the lens keeps heading to the pressed tab: onClick switches to it, a cancelled press brings it back
|
|
174
|
+
resync()
|
|
175
|
+
return true
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private fun position(point: Offset) = (if (axis == LiquidAxis.X) point.x else point.y) - firstCenter
|
|
179
|
+
|
|
180
|
+
private fun nearest(value: Float): Int =
|
|
181
|
+
if (pitch > 0) (value / pitch).roundToInt().coerceIn(0, (count - 1).coerceAtLeast(0)) else 0
|
|
182
|
+
|
|
183
|
+
private fun dragStart(point: Offset) {
|
|
184
|
+
dragging = true
|
|
185
|
+
resyncJob?.cancel()
|
|
186
|
+
hovered = nearest(position(point))
|
|
187
|
+
animateLift(1f)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private fun dragUpdate(point: Offset) {
|
|
191
|
+
val value = position(point)
|
|
192
|
+
val target = nearest(value)
|
|
193
|
+
if (target != hovered) {
|
|
194
|
+
hovered = target
|
|
195
|
+
onTick()
|
|
196
|
+
}
|
|
197
|
+
val follow = rubberBand(value, (count - 1) * pitch, pitch)
|
|
198
|
+
if (reduceMotion) {
|
|
199
|
+
destination = follow
|
|
200
|
+
scope.launch { offset.snapTo(follow) }
|
|
201
|
+
} else {
|
|
202
|
+
move(follow, spec = DRAG_SPRING)
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private fun dragEnd(point: Offset?, velocity: Float) {
|
|
207
|
+
dragging = false
|
|
208
|
+
animateLift(0f)
|
|
209
|
+
if (point == null) {
|
|
210
|
+
move(selectedIndex * pitch, velocity)
|
|
211
|
+
return
|
|
212
|
+
}
|
|
213
|
+
val target = nearest(position(point))
|
|
214
|
+
move(target * pitch, velocity)
|
|
215
|
+
onSelect(target)
|
|
216
|
+
resync()
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// The drag consumes the pointer once past the slop, which cancels the tab's clickable,
|
|
220
|
+
// so a press is either a tap or a drag.
|
|
221
|
+
val dragGesture: Modifier = Modifier.pointerInput(Unit) {
|
|
222
|
+
val slop = DRAG_SLOP.toPx()
|
|
223
|
+
awaitEachGesture {
|
|
224
|
+
val down = awaitFirstDown(requireUnconsumed = false)
|
|
225
|
+
val tracker = VelocityTracker()
|
|
226
|
+
tracker.addPosition(down.uptimeMillis, down.position)
|
|
227
|
+
var active = false
|
|
228
|
+
var last = down.position
|
|
229
|
+
while (true) {
|
|
230
|
+
val change = awaitPointerEvent().changes.firstOrNull { it.id == down.id }
|
|
231
|
+
if (change == null) {
|
|
232
|
+
if (active) dragEnd(null, 0f)
|
|
233
|
+
break
|
|
234
|
+
}
|
|
235
|
+
tracker.addPosition(change.uptimeMillis, change.position)
|
|
236
|
+
if (!change.pressed) {
|
|
237
|
+
if (active) {
|
|
238
|
+
change.consume()
|
|
239
|
+
val velocity = tracker.calculateVelocity()
|
|
240
|
+
dragEnd(change.position, if (axis == LiquidAxis.X) velocity.x else velocity.y)
|
|
241
|
+
}
|
|
242
|
+
break
|
|
243
|
+
}
|
|
244
|
+
last = change.position
|
|
245
|
+
if (!active && (last - down.position).getDistance() > slop) {
|
|
246
|
+
active = true
|
|
247
|
+
dragStart(last)
|
|
248
|
+
}
|
|
249
|
+
if (active) {
|
|
250
|
+
change.consume()
|
|
251
|
+
dragUpdate(last)
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
val bubbleTransform: Modifier = Modifier.graphicsLayer {
|
|
258
|
+
val along = 1 + stretch
|
|
259
|
+
val across = 1 - stretch * 0.5f
|
|
260
|
+
val lensX = 1 + lift.value * (LENS_SCALE_X - 1)
|
|
261
|
+
val lensY = 1 + lift.value * (LENS_SCALE_Y - 1)
|
|
262
|
+
if (axis == LiquidAxis.X) {
|
|
263
|
+
translationX = offset.value
|
|
264
|
+
scaleX = lensX * along
|
|
265
|
+
scaleY = lensY * across
|
|
266
|
+
} else {
|
|
267
|
+
translationY = offset.value
|
|
268
|
+
scaleX = lensX * across
|
|
269
|
+
scaleY = lensY * along
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
val barTransform: Modifier = Modifier.graphicsLayer {
|
|
274
|
+
scaleX = 1 + lift.value * BAR_SWELL
|
|
275
|
+
scaleY = scaleX
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// active tint of a tab follows the lens over it
|
|
279
|
+
fun activeAlpha(index: Int): Float {
|
|
280
|
+
if (pitch <= 0) return if (selectedIndex == index) 1f else 0f
|
|
281
|
+
return (1 - abs(offset.value - index * pitch) / pitch).coerceIn(0f, 1f)
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
@Composable
|
|
286
|
+
internal fun rememberDuoLiquidSelection(
|
|
287
|
+
count: Int,
|
|
288
|
+
pitch: Dp,
|
|
289
|
+
firstCenter: Dp,
|
|
290
|
+
axis: LiquidAxis,
|
|
291
|
+
index: Int,
|
|
292
|
+
onSelect: (Int) -> Unit,
|
|
293
|
+
onTick: () -> Unit,
|
|
294
|
+
): DuoLiquidSelection {
|
|
295
|
+
val scope = rememberCoroutineScope()
|
|
296
|
+
val density = LocalDensity.current
|
|
297
|
+
val selection = remember(axis) { DuoLiquidSelection(scope, axis, density.density) }
|
|
298
|
+
val reduceMotion = rememberReduceMotion()
|
|
299
|
+
val pitchPx = with(density) { pitch.toPx() }
|
|
300
|
+
selection.count = count
|
|
301
|
+
selection.pitch = pitchPx
|
|
302
|
+
selection.firstCenter = with(density) { firstCenter.toPx() }
|
|
303
|
+
selection.reduceMotion = reduceMotion
|
|
304
|
+
selection.onSelect = onSelect
|
|
305
|
+
selection.onTick = onTick
|
|
306
|
+
|
|
307
|
+
LaunchedEffect(selection, index, pitchPx) { selection.sync(index) }
|
|
308
|
+
LaunchedEffect(selection) { selection.trackStretch() }
|
|
309
|
+
return selection
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Press tracking for one tab: lifts the lens over it and, like the system tab bar, still picks a
|
|
313
|
+
// held tab when released. Returns the long-press handler for the tab's combinedClickable.
|
|
314
|
+
@Composable
|
|
315
|
+
internal fun DuoLiquidSelection.rememberPressTracking(
|
|
316
|
+
index: Int,
|
|
317
|
+
interactionSource: MutableInteractionSource,
|
|
318
|
+
onSelect: () -> Unit,
|
|
319
|
+
onLongPress: (() -> Unit)?,
|
|
320
|
+
): () -> Unit {
|
|
321
|
+
val longPressed = remember { mutableStateOf(false) }
|
|
322
|
+
val currentOnSelect by rememberUpdatedState(onSelect)
|
|
323
|
+
val currentOnLongPress by rememberUpdatedState(onLongPress)
|
|
324
|
+
LaunchedEffect(interactionSource, index) {
|
|
325
|
+
interactionSource.interactions.collect {
|
|
326
|
+
when (it) {
|
|
327
|
+
is PressInteraction.Press -> {
|
|
328
|
+
longPressed.value = false
|
|
329
|
+
pressIn(index)
|
|
330
|
+
}
|
|
331
|
+
is PressInteraction.Release -> if (pressOut() && longPressed.value) currentOnSelect()
|
|
332
|
+
is PressInteraction.Cancel -> pressOut()
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return remember {
|
|
337
|
+
{
|
|
338
|
+
longPressed.value = true
|
|
339
|
+
currentOnLongPress?.invoke()
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// The tab the lens lands on pops.
|
|
345
|
+
@Composable
|
|
346
|
+
internal fun DuoLiquidSelection.rememberIconPop(index: Int): Animatable<Float, *> {
|
|
347
|
+
val pop = remember { Animatable(1f) }
|
|
348
|
+
val isSelected = selectedIndex == index
|
|
349
|
+
val initial = remember { mutableStateOf(true) }
|
|
350
|
+
LaunchedEffect(isSelected) {
|
|
351
|
+
if (initial.value) {
|
|
352
|
+
initial.value = false
|
|
353
|
+
return@LaunchedEffect
|
|
354
|
+
}
|
|
355
|
+
if (isSelected && !reduceMotion) {
|
|
356
|
+
pop.animateTo(ICON_POP, tween(ICON_POP_MILLIS))
|
|
357
|
+
pop.animateTo(1f, SPRING)
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
return pop
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Single button version of the lift, e.g. the compressed rail.
|
|
364
|
+
@Composable
|
|
365
|
+
internal fun Modifier.pressLift(interactionSource: MutableInteractionSource): Modifier {
|
|
366
|
+
val lift = remember { Animatable(0f) }
|
|
367
|
+
val reduceMotion by rememberUpdatedState(rememberReduceMotion())
|
|
368
|
+
LaunchedEffect(interactionSource) {
|
|
369
|
+
interactionSource.interactions.collect {
|
|
370
|
+
when (it) {
|
|
371
|
+
is PressInteraction.Press -> if (!reduceMotion) launch { lift.animateTo(1f, SPRING) }
|
|
372
|
+
is PressInteraction.Release, is PressInteraction.Cancel -> launch { lift.animateTo(0f, SPRING) }
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return graphicsLayer {
|
|
377
|
+
scaleX = 1 + lift.value * (LENS_SCALE_X - 1) * 0.5f
|
|
378
|
+
scaleY = scaleX
|
|
379
|
+
}
|
|
380
|
+
}
|