Reorganise dockbar code
This commit is contained in:
@@ -1,78 +1,24 @@
|
|||||||
package dev.ulfrx.recipe.ui.components.dock
|
package dev.ulfrx.recipe.ui.components.dock
|
||||||
|
|
||||||
import androidx.compose.animation.core.Animatable
|
|
||||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
|
||||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.offset
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.foundation.text.BasicText
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateMapOf
|
import androidx.compose.runtime.mutableStateMapOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.alpha
|
|
||||||
import androidx.compose.ui.draw.scale
|
|
||||||
import androidx.compose.ui.input.pointer.PointerInputScope
|
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.input.pointer.positionChanged
|
|
||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
|
||||||
import androidx.compose.ui.layout.onSizeChanged
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
import androidx.compose.ui.layout.positionInParent
|
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
|
||||||
import androidx.compose.ui.semantics.Role
|
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
|
||||||
import androidx.compose.ui.semantics.onClick
|
|
||||||
import androidx.compose.ui.semantics.role
|
|
||||||
import androidx.compose.ui.semantics.selected
|
|
||||||
import androidx.compose.ui.semantics.semantics
|
|
||||||
import androidx.compose.ui.unit.Density
|
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.IntOffset
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.compose.ui.unit.times
|
|
||||||
import com.composeunstyled.UnstyledIcon
|
|
||||||
import dev.ulfrx.recipe.navigation.DockDestination
|
import dev.ulfrx.recipe.navigation.DockDestination
|
||||||
import dev.ulfrx.recipe.ui.components.glass.CircleGlassButton
|
import dev.ulfrx.recipe.ui.components.glass.CircleGlassButton
|
||||||
import dev.ulfrx.recipe.ui.components.glass.GlassSurface
|
|
||||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import recipe.composeapp.generated.resources.Res
|
import recipe.composeapp.generated.resources.Res
|
||||||
import recipe.composeapp.generated.resources.dock_expand_a11y
|
import recipe.composeapp.generated.resources.dock_expand_a11y
|
||||||
import kotlin.math.roundToInt
|
|
||||||
|
|
||||||
private val PressOverlayBleed = 4.dp
|
|
||||||
private val PressOverlayVerticalInset = 0.dp
|
|
||||||
private val ActiveIndicatorBleed = 4.dp
|
|
||||||
private val ActiveIndicatorVerticalInset = 5.dp
|
|
||||||
private val ActiveIndicatorEdgeInset = 5.dp
|
|
||||||
private val DockTabIconSize = 18.dp
|
|
||||||
private val DockTabIconLabelGap = 2.dp
|
|
||||||
private const val PressOverlayScale = 1.22f
|
|
||||||
private const val DockTabLabelFontSizeSp = 11
|
|
||||||
private const val DockTabLabelLineHeightSp = 13
|
|
||||||
private const val OverlaySlideDurationMs = 200
|
|
||||||
private const val OverlayFadeDurationMs = 120
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun DockBar(
|
fun DockBar(
|
||||||
@@ -84,13 +30,11 @@ fun DockBar(
|
|||||||
height: Dp = 56.dp,
|
height: Dp = 56.dp,
|
||||||
) {
|
) {
|
||||||
if (collapsed) {
|
if (collapsed) {
|
||||||
CircleGlassButton(
|
DockBarCollapsed(
|
||||||
onClick = { onTabSelect(active) },
|
active = active,
|
||||||
icon = active.icon,
|
onTabSelect = onTabSelect,
|
||||||
contentDescription = stringResource(Res.string.dock_expand_a11y),
|
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
size = height,
|
height = height,
|
||||||
iconTint = RecipeTheme.colors.accent,
|
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
DockBarExpanded(
|
DockBarExpanded(
|
||||||
@@ -103,6 +47,23 @@ fun DockBar(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DockBarCollapsed(
|
||||||
|
active: DockDestination,
|
||||||
|
onTabSelect: (DockDestination) -> Unit,
|
||||||
|
modifier: Modifier,
|
||||||
|
height: Dp,
|
||||||
|
) {
|
||||||
|
CircleGlassButton(
|
||||||
|
onClick = { onTabSelect(active) },
|
||||||
|
icon = active.icon,
|
||||||
|
contentDescription = stringResource(Res.string.dock_expand_a11y),
|
||||||
|
modifier = modifier,
|
||||||
|
size = height,
|
||||||
|
iconTint = RecipeTheme.colors.accent,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DockBarExpanded(
|
private fun DockBarExpanded(
|
||||||
destinations: List<DockDestination>,
|
destinations: List<DockDestination>,
|
||||||
@@ -112,7 +73,7 @@ private fun DockBarExpanded(
|
|||||||
height: Dp,
|
height: Dp,
|
||||||
) {
|
) {
|
||||||
val tabBounds = remember { mutableStateMapOf<Int, TabBounds>() }
|
val tabBounds = remember { mutableStateMapOf<Int, TabBounds>() }
|
||||||
var pressedX by remember { mutableStateOf<Float?>(null) }
|
var pressState by remember { mutableStateOf<DockPressState>(DockPressState.Idle) }
|
||||||
var dockWidthPx by remember { mutableStateOf(0f) }
|
var dockWidthPx by remember { mutableStateOf(0f) }
|
||||||
val activeIndex = destinations.indexOf(active).coerceAtLeast(0)
|
val activeIndex = destinations.indexOf(active).coerceAtLeast(0)
|
||||||
|
|
||||||
@@ -121,25 +82,33 @@ private fun DockBarExpanded(
|
|||||||
.height(height)
|
.height(height)
|
||||||
.onSizeChanged { dockWidthPx = it.width.toFloat() }
|
.onSizeChanged { dockWidthPx = it.width.toFloat() }
|
||||||
.pointerInput(destinations) {
|
.pointerInput(destinations) {
|
||||||
trackDockGesture(
|
trackDockGesture { event ->
|
||||||
onPressXChange = { pressedX = it },
|
when (event) {
|
||||||
onCommit = { x ->
|
is DockPressEvent.Pressing -> {
|
||||||
floorTabIndex(x, tabBounds)?.let { idx ->
|
pressState = DockPressState.Pressing(event.xPx)
|
||||||
|
}
|
||||||
|
is DockPressEvent.Released -> {
|
||||||
|
tabIndexAt(event.xPx, tabBounds)?.let { idx ->
|
||||||
onTabSelect(destinations[idx])
|
onTabSelect(destinations[idx])
|
||||||
}
|
}
|
||||||
},
|
pressState = DockPressState.Idle
|
||||||
)
|
}
|
||||||
|
DockPressEvent.Cancelled -> {
|
||||||
|
pressState = DockPressState.Idle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
DockSubstrate(cornerRadius = height / 2)
|
DockSubstrate(cornerRadius = height / 2)
|
||||||
DockActiveIndicatorLayer(
|
DockActiveIndicatorLayer(
|
||||||
activeIndex = activeIndex,
|
activeIndex = activeIndex,
|
||||||
visible = pressedX == null,
|
pressState = pressState,
|
||||||
tabBounds = tabBounds,
|
tabBounds = tabBounds,
|
||||||
dockWidthPx = dockWidthPx,
|
dockWidthPx = dockWidthPx,
|
||||||
)
|
)
|
||||||
DockPressOverlayLayer(
|
DockPressOverlayLayer(
|
||||||
pressedX = pressedX,
|
pressState = pressState,
|
||||||
activeIndex = activeIndex,
|
activeIndex = activeIndex,
|
||||||
tabBounds = tabBounds,
|
tabBounds = tabBounds,
|
||||||
dockWidthPx = dockWidthPx,
|
dockWidthPx = dockWidthPx,
|
||||||
@@ -155,281 +124,3 @@ private fun DockBarExpanded(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun DockSubstrate(cornerRadius: Dp) {
|
|
||||||
GlassSurface(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
cornerRadius = cornerRadius,
|
|
||||||
recordAsSource = true,
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun DockActiveIndicatorLayer(
|
|
||||||
activeIndex: Int,
|
|
||||||
visible: Boolean,
|
|
||||||
tabBounds: Map<Int, TabBounds>,
|
|
||||||
dockWidthPx: Float,
|
|
||||||
) {
|
|
||||||
val alpha by animateFloatAsState(
|
|
||||||
targetValue = if (visible) 1f else 0f,
|
|
||||||
animationSpec = tween(durationMillis = OverlayFadeDurationMs, easing = FastOutSlowInEasing),
|
|
||||||
label = "Dock active indicator alpha",
|
|
||||||
)
|
|
||||||
val bounds = tabBounds[activeIndex] ?: return
|
|
||||||
if (alpha <= 0f || dockWidthPx <= 0f) return
|
|
||||||
|
|
||||||
val density = LocalDensity.current
|
|
||||||
val bbox = activeIndicatorBboxFor(bounds, dockWidthPx, density)
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.offset { IntOffset(bbox.leftPx.roundToInt(), 0) }
|
|
||||||
.width(with(density) { bbox.widthPx.toDp() })
|
|
||||||
.fillMaxHeight()
|
|
||||||
.padding(vertical = ActiveIndicatorVerticalInset)
|
|
||||||
.alpha(alpha)
|
|
||||||
.background(
|
|
||||||
color = RecipeTheme.colors.chromeActive,
|
|
||||||
shape = RoundedCornerShape(50),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun DockPressOverlayLayer(
|
|
||||||
pressedX: Float?,
|
|
||||||
activeIndex: Int,
|
|
||||||
tabBounds: Map<Int, TabBounds>,
|
|
||||||
dockWidthPx: Float,
|
|
||||||
dockHeight: Dp,
|
|
||||||
) {
|
|
||||||
val activeBounds = tabBounds[activeIndex] ?: return
|
|
||||||
val activeCenterX = activeBounds.offsetXPx + activeBounds.widthPx / 2f
|
|
||||||
|
|
||||||
val density = LocalDensity.current
|
|
||||||
val bleedPx = with(density) { PressOverlayBleed.toPx() }
|
|
||||||
val overlayWidthPx = activeBounds.widthPx + 2 * bleedPx
|
|
||||||
val centerXMin = overlayWidthPx / 2f
|
|
||||||
val centerXMax = (dockWidthPx - overlayWidthPx / 2f).coerceAtLeast(centerXMin)
|
|
||||||
val clampedPressedX = pressedX?.coerceIn(centerXMin, centerXMax)
|
|
||||||
val overlayCenterX = rememberPressOverlayCenterX(clampedPressedX, activeCenterX)
|
|
||||||
|
|
||||||
val alpha by animateFloatAsState(
|
|
||||||
targetValue = if (pressedX != null) 1f else 0f,
|
|
||||||
animationSpec = tween(durationMillis = OverlayFadeDurationMs, easing = FastOutSlowInEasing),
|
|
||||||
label = "Dock press overlay alpha",
|
|
||||||
)
|
|
||||||
if (alpha <= 0f || dockWidthPx <= 0f) return
|
|
||||||
|
|
||||||
val cornerRadius = (dockHeight - 2 * PressOverlayVerticalInset) / 2
|
|
||||||
val leftPx = overlayCenterX - overlayWidthPx / 2f
|
|
||||||
GlassSurface(
|
|
||||||
modifier = Modifier
|
|
||||||
.offset { IntOffset(leftPx.roundToInt(), 0) }
|
|
||||||
.width(with(density) { overlayWidthPx.toDp() })
|
|
||||||
.fillMaxHeight()
|
|
||||||
.padding(vertical = PressOverlayVerticalInset)
|
|
||||||
.scale(PressOverlayScale)
|
|
||||||
.alpha(alpha),
|
|
||||||
cornerRadius = cornerRadius,
|
|
||||||
glassStyle = RecipeTheme.glass.dockPress,
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun DockTabRow(
|
|
||||||
destinations: List<DockDestination>,
|
|
||||||
activeIndex: Int,
|
|
||||||
tabBounds: Map<Int, TabBounds>,
|
|
||||||
dockWidthPx: Float,
|
|
||||||
onTabSelectFromA11y: (DockDestination) -> Unit,
|
|
||||||
onTabBoundsChange: (Int, TabBounds) -> Unit,
|
|
||||||
) {
|
|
||||||
val density = LocalDensity.current
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
destinations.forEachIndexed { index, destination ->
|
|
||||||
val cellBounds = tabBounds[index]
|
|
||||||
val contentOffsetPx = if (cellBounds != null && dockWidthPx > 0f) {
|
|
||||||
val bbox = activeIndicatorBboxFor(cellBounds, dockWidthPx, density)
|
|
||||||
val cellCenterX = cellBounds.offsetXPx + cellBounds.widthPx / 2f
|
|
||||||
bbox.centerPx - cellCenterX
|
|
||||||
} else {
|
|
||||||
0f
|
|
||||||
}
|
|
||||||
DockTabItem(
|
|
||||||
destination = destination,
|
|
||||||
isActive = index == activeIndex,
|
|
||||||
contentOffsetPx = contentOffsetPx,
|
|
||||||
onSelect = { onTabSelectFromA11y(destination) },
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.fillMaxHeight()
|
|
||||||
.onGloballyPositioned { coords ->
|
|
||||||
onTabBoundsChange(
|
|
||||||
index,
|
|
||||||
TabBounds(
|
|
||||||
offsetXPx = coords.positionInParent().x,
|
|
||||||
widthPx = coords.size.width.toFloat(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun DockTabItem(
|
|
||||||
destination: DockDestination,
|
|
||||||
isActive: Boolean,
|
|
||||||
contentOffsetPx: Float,
|
|
||||||
onSelect: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
val label = stringResource(destination.labelRes)
|
|
||||||
val a11yLabel = if (isActive) "$label, aktywna" else label
|
|
||||||
val tint = RecipeTheme.colors.content
|
|
||||||
Box(
|
|
||||||
modifier = modifier.semantics {
|
|
||||||
role = Role.Tab
|
|
||||||
selected = isActive
|
|
||||||
contentDescription = a11yLabel
|
|
||||||
onClick {
|
|
||||||
onSelect()
|
|
||||||
true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.offset { IntOffset(contentOffsetPx.roundToInt(), 0) },
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.Center,
|
|
||||||
) {
|
|
||||||
UnstyledIcon(
|
|
||||||
imageVector = destination.icon,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = tint,
|
|
||||||
modifier = Modifier.size(DockTabIconSize),
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.size(DockTabIconLabelGap))
|
|
||||||
BasicText(
|
|
||||||
text = label,
|
|
||||||
style = RecipeTheme.typography.label.copy(
|
|
||||||
color = tint,
|
|
||||||
fontSize = DockTabLabelFontSizeSp.sp,
|
|
||||||
lineHeight = DockTabLabelLineHeightSp.sp,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun rememberPressOverlayCenterX(
|
|
||||||
pressedCenterX: Float?,
|
|
||||||
activeCenterX: Float,
|
|
||||||
): Float {
|
|
||||||
val animatable = remember { Animatable(activeCenterX) }
|
|
||||||
var wasPressed by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
LaunchedEffect(pressedCenterX, activeCenterX) {
|
|
||||||
when {
|
|
||||||
pressedCenterX == null -> {
|
|
||||||
wasPressed = false
|
|
||||||
animatable.animateTo(
|
|
||||||
targetValue = activeCenterX,
|
|
||||||
animationSpec = tween(durationMillis = OverlaySlideDurationMs, easing = FastOutSlowInEasing),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
!wasPressed -> {
|
|
||||||
wasPressed = true
|
|
||||||
animatable.animateTo(
|
|
||||||
targetValue = pressedCenterX,
|
|
||||||
animationSpec = tween(durationMillis = OverlaySlideDurationMs, easing = FastOutSlowInEasing),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
animatable.snapTo(pressedCenterX)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return animatable.value
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun PointerInputScope.trackDockGesture(
|
|
||||||
onPressXChange: (Float?) -> Unit,
|
|
||||||
onCommit: (Float) -> Unit,
|
|
||||||
) {
|
|
||||||
awaitEachGesture {
|
|
||||||
val down = awaitFirstDown(requireUnconsumed = false)
|
|
||||||
down.consume()
|
|
||||||
val pointerId = down.id
|
|
||||||
onPressXChange(down.position.x)
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
val event = awaitPointerEvent()
|
|
||||||
val change = event.changes.firstOrNull { it.id == pointerId }
|
|
||||||
if (change == null) {
|
|
||||||
onPressXChange(null)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if (!change.pressed) {
|
|
||||||
onCommit(change.position.x)
|
|
||||||
onPressXChange(null)
|
|
||||||
change.consume()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if (change.positionChanged()) {
|
|
||||||
onPressXChange(change.position.x)
|
|
||||||
}
|
|
||||||
change.consume()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun floorTabIndex(x: Float, bounds: Map<Int, TabBounds>): Int? {
|
|
||||||
if (bounds.isEmpty()) return null
|
|
||||||
val sorted = bounds.entries.sortedBy { it.value.offsetXPx }
|
|
||||||
var result = sorted.first().key
|
|
||||||
for (entry in sorted) {
|
|
||||||
if (entry.value.offsetXPx <= x) {
|
|
||||||
result = entry.key
|
|
||||||
} else {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
private data class TabBounds(
|
|
||||||
val offsetXPx: Float,
|
|
||||||
val widthPx: Float,
|
|
||||||
)
|
|
||||||
|
|
||||||
private data class ActiveIndicatorBbox(
|
|
||||||
val leftPx: Float,
|
|
||||||
val rightPx: Float,
|
|
||||||
) {
|
|
||||||
val widthPx: Float get() = (rightPx - leftPx).coerceAtLeast(0f)
|
|
||||||
val centerPx: Float get() = (leftPx + rightPx) / 2f
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun activeIndicatorBboxFor(
|
|
||||||
cell: TabBounds,
|
|
||||||
dockWidthPx: Float,
|
|
||||||
density: Density,
|
|
||||||
): ActiveIndicatorBbox {
|
|
||||||
val bleedPx = with(density) { ActiveIndicatorBleed.toPx() }
|
|
||||||
val edgeInsetPx = with(density) { ActiveIndicatorEdgeInset.toPx() }
|
|
||||||
val left = (cell.offsetXPx - bleedPx).coerceAtLeast(edgeInsetPx)
|
|
||||||
val right = (cell.offsetXPx + cell.widthPx + bleedPx).coerceAtMost(dockWidthPx - edgeInsetPx)
|
|
||||||
return ActiveIndicatorBbox(left, right)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package dev.ulfrx.recipe.ui.components.dock
|
||||||
|
|
||||||
|
import androidx.compose.ui.unit.Density
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
private val ActiveIndicatorBleed = 4.dp
|
||||||
|
private val ActiveIndicatorEdgeInset = 5.dp
|
||||||
|
|
||||||
|
internal data class TabBounds(
|
||||||
|
val offsetXPx: Float,
|
||||||
|
val widthPx: Float,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal sealed interface DockPressState {
|
||||||
|
data object Idle : DockPressState
|
||||||
|
data class Pressing(val xPx: Float) : DockPressState
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed interface DockPressEvent {
|
||||||
|
data class Pressing(val xPx: Float) : DockPressEvent
|
||||||
|
data class Released(val xPx: Float) : DockPressEvent
|
||||||
|
data object Cancelled : DockPressEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
internal data class ActiveIndicatorBbox(
|
||||||
|
val leftPx: Float,
|
||||||
|
val rightPx: Float,
|
||||||
|
) {
|
||||||
|
val widthPx: Float get() = (rightPx - leftPx).coerceAtLeast(0f)
|
||||||
|
val centerPx: Float get() = (leftPx + rightPx) / 2f
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun activeIndicatorBboxFor(
|
||||||
|
cell: TabBounds,
|
||||||
|
dockWidthPx: Float,
|
||||||
|
density: Density,
|
||||||
|
): ActiveIndicatorBbox {
|
||||||
|
val bleedPx = with(density) { ActiveIndicatorBleed.toPx() }
|
||||||
|
val edgeInsetPx = with(density) { ActiveIndicatorEdgeInset.toPx() }
|
||||||
|
val left = (cell.offsetXPx - bleedPx).coerceAtLeast(edgeInsetPx)
|
||||||
|
val right = (cell.offsetXPx + cell.widthPx + bleedPx).coerceAtMost(dockWidthPx - edgeInsetPx)
|
||||||
|
return ActiveIndicatorBbox(left, right)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun tabIndexAt(x: Float, bounds: Map<Int, TabBounds>): Int? {
|
||||||
|
if (bounds.isEmpty()) return null
|
||||||
|
val sorted = bounds.entries.sortedBy { it.value.offsetXPx }
|
||||||
|
var result = sorted.first().key
|
||||||
|
for (entry in sorted) {
|
||||||
|
if (entry.value.offsetXPx <= x) {
|
||||||
|
result = entry.key
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package dev.ulfrx.recipe.ui.components.dock
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.Animatable
|
||||||
|
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||||
|
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.input.pointer.PointerInputScope
|
||||||
|
import androidx.compose.ui.input.pointer.positionChanged
|
||||||
|
import dev.ulfrx.recipe.ui.components.dock.DockPressEvent.Cancelled
|
||||||
|
import dev.ulfrx.recipe.ui.components.dock.DockPressEvent.Pressing
|
||||||
|
import dev.ulfrx.recipe.ui.components.dock.DockPressEvent.Released
|
||||||
|
|
||||||
|
private const val OverlaySlideDurationMs = 200
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun rememberPressOverlayCenterX(
|
||||||
|
pressedCenterX: Float?,
|
||||||
|
activeCenterX: Float,
|
||||||
|
): Float {
|
||||||
|
val animatable = remember { Animatable(activeCenterX) }
|
||||||
|
var wasPressed by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
LaunchedEffect(pressedCenterX, activeCenterX) {
|
||||||
|
when {
|
||||||
|
pressedCenterX == null -> {
|
||||||
|
wasPressed = false
|
||||||
|
animatable.animateTo(
|
||||||
|
targetValue = activeCenterX,
|
||||||
|
animationSpec = tween(durationMillis = OverlaySlideDurationMs, easing = FastOutSlowInEasing),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
!wasPressed -> {
|
||||||
|
wasPressed = true
|
||||||
|
animatable.animateTo(
|
||||||
|
targetValue = pressedCenterX,
|
||||||
|
animationSpec = tween(durationMillis = OverlaySlideDurationMs, easing = FastOutSlowInEasing),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
animatable.snapTo(pressedCenterX)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return animatable.value
|
||||||
|
}
|
||||||
|
|
||||||
|
internal suspend fun PointerInputScope.trackDockGesture(onPressEvent: (DockPressEvent) -> Unit) {
|
||||||
|
awaitEachGesture {
|
||||||
|
val pressDown = awaitFirstDown(requireUnconsumed = false)
|
||||||
|
pressDown.consume()
|
||||||
|
val pointerId = pressDown.id
|
||||||
|
onPressEvent(Pressing(pressDown.position.x))
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
val pointerEvent = awaitPointerEvent()
|
||||||
|
val pressChange = pointerEvent.changes.firstOrNull { it.id == pointerId }
|
||||||
|
if (pressChange == null) {
|
||||||
|
onPressEvent(Cancelled)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (!pressChange.pressed) {
|
||||||
|
onPressEvent(Released(pressChange.position.x))
|
||||||
|
pressChange.consume()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (pressChange.positionChanged()) {
|
||||||
|
onPressEvent(Pressing(pressChange.position.x))
|
||||||
|
}
|
||||||
|
pressChange.consume()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package dev.ulfrx.recipe.ui.components.dock
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
|
import androidx.compose.ui.draw.scale
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.times
|
||||||
|
import dev.ulfrx.recipe.ui.components.glass.GlassSurface
|
||||||
|
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
private val PressOverlayBleed = 4.dp
|
||||||
|
private val PressOverlayVerticalInset = 0.dp
|
||||||
|
private val ActiveIndicatorVerticalInset = 5.dp
|
||||||
|
private const val PressOverlayScale = 1.22f
|
||||||
|
private const val OverlayFadeDurationMs = 120
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun DockSubstrate(cornerRadius: Dp) {
|
||||||
|
GlassSurface(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
cornerRadius = cornerRadius,
|
||||||
|
recordAsSource = true,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun DockActiveIndicatorLayer(
|
||||||
|
activeIndex: Int,
|
||||||
|
pressState: DockPressState,
|
||||||
|
tabBounds: Map<Int, TabBounds>,
|
||||||
|
dockWidthPx: Float,
|
||||||
|
) {
|
||||||
|
val alpha by animateFloatAsState(
|
||||||
|
targetValue = if (pressState is DockPressState.Idle) 1f else 0f,
|
||||||
|
animationSpec = tween(durationMillis = OverlayFadeDurationMs, easing = FastOutSlowInEasing),
|
||||||
|
label = "Dock active indicator alpha",
|
||||||
|
)
|
||||||
|
val bounds = tabBounds[activeIndex] ?: return
|
||||||
|
if (alpha <= 0f || dockWidthPx <= 0f) return
|
||||||
|
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val bbox = activeIndicatorBboxFor(bounds, dockWidthPx, density)
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.offset { IntOffset(bbox.leftPx.roundToInt(), 0) }
|
||||||
|
.width(with(density) { bbox.widthPx.toDp() })
|
||||||
|
.fillMaxHeight()
|
||||||
|
.padding(vertical = ActiveIndicatorVerticalInset)
|
||||||
|
.alpha(alpha)
|
||||||
|
.background(
|
||||||
|
color = RecipeTheme.colors.chromeActive,
|
||||||
|
shape = RoundedCornerShape(50),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun DockPressOverlayLayer(
|
||||||
|
pressState: DockPressState,
|
||||||
|
activeIndex: Int,
|
||||||
|
tabBounds: Map<Int, TabBounds>,
|
||||||
|
dockWidthPx: Float,
|
||||||
|
dockHeight: Dp,
|
||||||
|
) {
|
||||||
|
val activeBounds = tabBounds[activeIndex] ?: return
|
||||||
|
val activeCenterX = activeBounds.offsetXPx + activeBounds.widthPx / 2f
|
||||||
|
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val bleedPx = with(density) { PressOverlayBleed.toPx() }
|
||||||
|
val overlayWidthPx = activeBounds.widthPx + 2 * bleedPx
|
||||||
|
val centerXMin = overlayWidthPx / 2f
|
||||||
|
val centerXMax = (dockWidthPx - overlayWidthPx / 2f).coerceAtLeast(centerXMin)
|
||||||
|
val pressingXPx = (pressState as? DockPressState.Pressing)?.xPx
|
||||||
|
val clampedPressPositionPx = pressingXPx?.coerceIn(centerXMin, centerXMax)
|
||||||
|
val overlayCenterX = rememberPressOverlayCenterX(clampedPressPositionPx, activeCenterX)
|
||||||
|
|
||||||
|
val alpha by animateFloatAsState(
|
||||||
|
targetValue = if (pressState is DockPressState.Pressing) 1f else 0f,
|
||||||
|
animationSpec = tween(durationMillis = OverlayFadeDurationMs, easing = FastOutSlowInEasing),
|
||||||
|
label = "Dock press overlay alpha",
|
||||||
|
)
|
||||||
|
if (alpha <= 0f || dockWidthPx <= 0f) return
|
||||||
|
|
||||||
|
val cornerRadius = (dockHeight - 2 * PressOverlayVerticalInset) / 2
|
||||||
|
val leftPx = overlayCenterX - overlayWidthPx / 2f
|
||||||
|
GlassSurface(
|
||||||
|
modifier = Modifier
|
||||||
|
.offset { IntOffset(leftPx.roundToInt(), 0) }
|
||||||
|
.width(with(density) { overlayWidthPx.toDp() })
|
||||||
|
.fillMaxHeight()
|
||||||
|
.padding(vertical = PressOverlayVerticalInset)
|
||||||
|
.scale(PressOverlayScale)
|
||||||
|
.alpha(alpha),
|
||||||
|
cornerRadius = cornerRadius,
|
||||||
|
glassStyle = RecipeTheme.glass.dockPress,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package dev.ulfrx.recipe.ui.components.dock
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.text.BasicText
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
|
import androidx.compose.ui.layout.positionInParent
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.onClick
|
||||||
|
import androidx.compose.ui.semantics.role
|
||||||
|
import androidx.compose.ui.semantics.selected
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.composeunstyled.UnstyledIcon
|
||||||
|
import dev.ulfrx.recipe.navigation.DockDestination
|
||||||
|
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
private val DockTabIconSize = 18.dp
|
||||||
|
private val DockTabIconLabelGap = 2.dp
|
||||||
|
private const val DockTabLabelFontSizeSp = 11
|
||||||
|
private const val DockTabLabelLineHeightSp = 13
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun DockTabRow(
|
||||||
|
destinations: List<DockDestination>,
|
||||||
|
activeIndex: Int,
|
||||||
|
tabBounds: Map<Int, TabBounds>,
|
||||||
|
dockWidthPx: Float,
|
||||||
|
onTabSelectFromA11y: (DockDestination) -> Unit,
|
||||||
|
onTabBoundsChange: (Int, TabBounds) -> Unit,
|
||||||
|
) {
|
||||||
|
val density = LocalDensity.current
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
destinations.forEachIndexed { index, destination ->
|
||||||
|
val cellBounds = tabBounds[index]
|
||||||
|
val contentOffsetPx = if (cellBounds != null && dockWidthPx > 0f) {
|
||||||
|
val bbox = activeIndicatorBboxFor(cellBounds, dockWidthPx, density)
|
||||||
|
val cellCenterX = cellBounds.offsetXPx + cellBounds.widthPx / 2f
|
||||||
|
bbox.centerPx - cellCenterX
|
||||||
|
} else {
|
||||||
|
0f
|
||||||
|
}
|
||||||
|
DockTabItem(
|
||||||
|
destination = destination,
|
||||||
|
isActive = index == activeIndex,
|
||||||
|
contentOffsetPx = contentOffsetPx,
|
||||||
|
onSelect = { onTabSelectFromA11y(destination) },
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.onGloballyPositioned { coords ->
|
||||||
|
onTabBoundsChange(
|
||||||
|
index,
|
||||||
|
TabBounds(
|
||||||
|
offsetXPx = coords.positionInParent().x,
|
||||||
|
widthPx = coords.size.width.toFloat(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DockTabItem(
|
||||||
|
destination: DockDestination,
|
||||||
|
isActive: Boolean,
|
||||||
|
contentOffsetPx: Float,
|
||||||
|
onSelect: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val label = stringResource(destination.labelRes)
|
||||||
|
val a11yLabel = if (isActive) "$label, aktywna" else label
|
||||||
|
val tint = RecipeTheme.colors.content
|
||||||
|
Box(
|
||||||
|
modifier = modifier.semantics {
|
||||||
|
role = Role.Tab
|
||||||
|
selected = isActive
|
||||||
|
contentDescription = a11yLabel
|
||||||
|
onClick {
|
||||||
|
onSelect()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.offset { IntOffset(contentOffsetPx.roundToInt(), 0) },
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
) {
|
||||||
|
UnstyledIcon(
|
||||||
|
imageVector = destination.icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = tint,
|
||||||
|
modifier = Modifier.size(DockTabIconSize),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.size(DockTabIconLabelGap))
|
||||||
|
BasicText(
|
||||||
|
text = label,
|
||||||
|
style = RecipeTheme.typography.label.copy(
|
||||||
|
color = tint,
|
||||||
|
fontSize = DockTabLabelFontSizeSp.sp,
|
||||||
|
lineHeight = DockTabLabelLineHeightSp.sp,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user