Reorganise dockbar code

This commit is contained in:
2026-05-17 22:23:24 +02:00
parent 8eda4b04ee
commit fb00df856a
5 changed files with 423 additions and 348 deletions

View File

@@ -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<DockDestination>,
@@ -112,7 +73,7 @@ private fun DockBarExpanded(
height: Dp,
) {
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) }
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 ->
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<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)
}

View File

@@ -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
}

View File

@@ -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()
}
}
}

View File

@@ -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,
) {}
}

View File

@@ -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,
),
)
}
}
}