diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockBar.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockBar.kt index a9b1ee0..2bff98d 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockBar.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockBar.kt @@ -1,78 +1,24 @@ 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.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.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.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment 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.positionChanged -import androidx.compose.ui.layout.onGloballyPositioned 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.IntOffset 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.ui.components.glass.CircleGlassButton -import dev.ulfrx.recipe.ui.components.glass.GlassSurface import dev.ulfrx.recipe.ui.theme.RecipeTheme import org.jetbrains.compose.resources.stringResource import recipe.composeapp.generated.resources.Res 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 fun DockBar( @@ -84,13 +30,11 @@ fun DockBar( height: Dp = 56.dp, ) { if (collapsed) { - CircleGlassButton( - onClick = { onTabSelect(active) }, - icon = active.icon, - contentDescription = stringResource(Res.string.dock_expand_a11y), + DockBarCollapsed( + active = active, + onTabSelect = onTabSelect, modifier = modifier, - size = height, - iconTint = RecipeTheme.colors.accent, + height = height, ) } else { 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 private fun DockBarExpanded( destinations: List, @@ -112,7 +73,7 @@ private fun DockBarExpanded( height: Dp, ) { val tabBounds = remember { mutableStateMapOf() } - var pressedX by remember { mutableStateOf(null) } + var pressState by remember { mutableStateOf(DockPressState.Idle) } var dockWidthPx by remember { mutableStateOf(0f) } val activeIndex = destinations.indexOf(active).coerceAtLeast(0) @@ -121,25 +82,33 @@ private fun DockBarExpanded( .height(height) .onSizeChanged { dockWidthPx = it.width.toFloat() } .pointerInput(destinations) { - trackDockGesture( - onPressXChange = { pressedX = it }, - onCommit = { x -> - floorTabIndex(x, tabBounds)?.let { idx -> - onTabSelect(destinations[idx]) + trackDockGesture { event -> + when (event) { + is DockPressEvent.Pressing -> { + pressState = DockPressState.Pressing(event.xPx) } - }, - ) + is DockPressEvent.Released -> { + tabIndexAt(event.xPx, tabBounds)?.let { idx -> + onTabSelect(destinations[idx]) + } + pressState = DockPressState.Idle + } + DockPressEvent.Cancelled -> { + pressState = DockPressState.Idle + } + } + } }, ) { DockSubstrate(cornerRadius = height / 2) DockActiveIndicatorLayer( activeIndex = activeIndex, - visible = pressedX == null, + pressState = pressState, tabBounds = tabBounds, dockWidthPx = dockWidthPx, ) DockPressOverlayLayer( - pressedX = pressedX, + pressState = pressState, activeIndex = activeIndex, tabBounds = tabBounds, 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, - 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, - 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, - activeIndex: Int, - tabBounds: Map, - 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? { - 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) -} diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockGeometry.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockGeometry.kt new file mode 100644 index 0000000..d3f884f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockGeometry.kt @@ -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? { + 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 +} diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockGesture.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockGesture.kt new file mode 100644 index 0000000..8d62445 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockGesture.kt @@ -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() + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockLayers.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockLayers.kt new file mode 100644 index 0000000..1e78c61 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockLayers.kt @@ -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, + 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, + 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, + ) {} +} diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockTabRow.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockTabRow.kt new file mode 100644 index 0000000..ebad459 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockTabRow.kt @@ -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, + activeIndex: Int, + tabBounds: Map, + 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, + ), + ) + } + } +}