@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 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.
@@ -40,7 +40,7 @@ kotlin {
40
40
  }
41
41
 
42
42
  cocoapods {
43
- version = "0.168.1-beta.24-debug"
43
+ version = "0.168.1-beta.25-debug"
44
44
  summary = "IOS Shared module"
45
45
  homepage = "https://momo.vn"
46
46
  ios.deploymentTarget = "15.0"
@@ -1,6 +1,6 @@
1
1
  Pod::Spec.new do |spec|
2
2
  spec.name = 'compose'
3
- spec.version = '0.168.1-beta.24-debug'
3
+ spec.version = '0.168.1-beta.8-debug'
4
4
  spec.homepage = 'https://momo.vn'
5
5
  spec.source = { :http=> ''}
6
6
  spec.authors = ''
@@ -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
- Box(modifier = Modifier
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
- .activeOpacityClickable {
145
- onClick()
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
- BadgeDot(size = DotSize.Small, modifier = badgeAnchor.offset(x = 4.dp, y = (-4).dp))
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
- Badge(badgeLabel, modifier = badgeAnchor.offset(x = if (isNumber) 8.dp else 12.dp, y = (-8).dp))
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
+ }