Reorganise dockbar code
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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