Lane guidance draws the road ahead, with the lanes to use filled in and said in words

This commit is contained in:
Seb3thehacker committed 2026-11-05 16:21:36 -08:00
1 parent 163e206715
commit 5d1d2337f0
4 files changed
+333 -73

No files matched your search

@@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@@ -103,7 +104,7 @@ internal fun ClusterScreen(marginX: Int, marginY: Int, densityDpi: Int, clearAre
if (t != null) {
BoxWithConstraints(Modifier.weight(0.42f).fillMaxHeight(), contentAlignment = Alignment.CenterStart) {
// Narrower than alone: the arrow and distance alone are about 12 units wide.
TurnCard(t, minOf(maxHeight / 8.5f, maxWidth / 12))
TurnCard(t, minOf(maxHeight / t.rows, maxWidth / 12))
}
Spacer(Modifier.width(12.dp))
}
@@ -111,13 +112,12 @@ internal fun ClusterScreen(marginX: Int, marginY: Int, densityDpi: Int, clearAre
}
return@BoxWithConstraints
}
// The card is about 8.5 units tall with two lines of directions, and 14 wide: fit both ways.
val unit = minOf(maxHeight / 8.5f, maxWidth / 14)
if (t == null) {
NowPlayingLine(Modifier.align(Alignment.Center))
return@BoxWithConstraints
}
TurnCard(t, unit)
// The card is t.rows units tall and about 14 wide: fit both ways.
TurnCard(t, minOf(maxHeight / t.rows, maxWidth / 14))
}
}
}
@@ -187,6 +187,9 @@ private fun drawsOnCluster(context: Context, component: ComponentName): Boolean
private const val FEATURE_CLUSTER = "androidx.car.app.category.FEATURE_CLUSTER"
private const val CLUSTER_SESSION = "gearslip-cluster"
/** The lane to be in, on the cockpit: green, like the main screen's header, against the black. */
private val LANE_FILL = Color(0xFF2F7D6D)
/**
* Start, top, end and bottom padding around the card inside the visible picture. The car's own
* insets win when they leave a usable area; a wide strip, where dials sit at both ends and a bar
@@ -225,6 +228,20 @@ private fun TurnCard(t: ClusterTurn, unit: Dp) {
overflow = TextOverflow.Ellipsis,
)
}
if (t.lanes.isNotEmpty()) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
LaneRoad(t.lanes, Modifier.width(unit * 10).height(unit * 2.4f), LANE_FILL, Color.White, Color.Black)
laneHint(t.lanes)?.let {
Text(
it,
Modifier.padding(top = unit * 0.3f),
fontSize = with(density) { (unit * 0.8f).toSp() },
color = Color.White.copy(alpha = 0.85f),
maxLines = 1,
)
}
}
}
if (t.trip.isNotEmpty()) {
Text(
t.trip,
@@ -256,7 +273,11 @@ private class ClusterTurn(
val distance: String,
val what: String,
val trip: String,
val lanes: List<androidx.car.app.navigation.model.Lane>,
) {
/** How many units tall the card is: two lines of directions, and the lanes when there are some. */
val rows: Float get() = if (lanes.isEmpty()) 8.5f else 12f
companion object {
fun now(): ClusterTurn? {
val turn = NavTurn.current() ?: return null
@@ -272,7 +293,7 @@ private class ClusterTurn(
e.arrivalTimeAtDestination.clockTime(),
).filter { it.isNotEmpty() }.joinToString(" · ")
}.orEmpty()
return ClusterTurn(step.maneuver?.icon, turn.distance.display(), what, trip)
return ClusterTurn(step.maneuver?.icon, turn.distance.display(), what, trip, step.lanes.orEmpty())
}
}
}
@@ -0,0 +1,243 @@
package app.seb3thehacker.gearslip.car
import androidx.car.app.navigation.model.Lane
import androidx.car.app.navigation.model.LaneDirection
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import app.seb3thehacker.gearslip.R
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.cos
import kotlin.math.sin
/** The dark band the lanes sit on under the turn card's header, tinted toward the theme. */
@Composable
internal fun laneBandColor(): Color = lerp(Color(0xFF0E1417), MaterialTheme.colorScheme.primary, 0.14f)
/**
* The lanes before a manoeuvre as a dark band under the turn card's header: the road ahead in
* perspective, with the lane or lanes to be in filled with the header's colour.
*/
@Composable
internal fun LaneBand(lanes: List<Lane>, modifier: Modifier = Modifier) {
val scheme = MaterialTheme.colorScheme
val band = laneBandColor()
LaneRoad(
lanes,
modifier.background(band).padding(horizontal = 16.dp, vertical = 10.dp).fillMaxWidth().height(52.dp),
scheme.primaryContainer,
scheme.onPrimaryContainer,
band,
)
}
/** Which lanes to use, in words, on the same band: "Use the left lane". Nothing when it can't say. */
@Composable
internal fun LaneHintLine(lanes: List<Lane>, modifier: Modifier = Modifier) {
val hint = laneHint(lanes) ?: return
Text(
hint,
modifier.background(laneBandColor()).padding(start = 16.dp, end = 16.dp, bottom = 10.dp),
style = ChromeType.label,
color = Color.White,
textAlign = TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
/**
* The road ahead, narrowing toward the top: solid edges, dashed lines between lanes, the
* recommended lanes filled with [fill] and every lane's arrows drawn from its directions, the
* recommended ones in [onFill] and the rest faded toward [ground], the colour behind the road.
* The arrows are painted on the road, so they narrow with it. Lanes run left to right, as the app
* sends them.
*/
@Composable
internal fun LaneRoad(lanes: List<Lane>, modifier: Modifier, fill: Color, onFill: Color, ground: Color, line: Color = Color.White) {
val named = lanes.map {
stringResource(if (it.recommended()) R.string.nav_lane_recommended else R.string.nav_lane)
}.joinToString(", ")
Canvas(modifier.semantics { contentDescription = named }) {
val n = lanes.size
if (n == 0) return@Canvas
val h = size.height
// Each lane at most a little wider than the road is tall, so two lanes don't fill a whole card.
val bottomWidth = minOf(size.width, h * 1.6f * n)
val topWidth = bottomWidth * 0.86f
fun width(y: Float) = topWidth + (bottomWidth - topWidth) * (y / h)
// The x of lane edge [edge] (0 to n, fractions for the middle of a lane) at height [y].
fun x(edge: Float, y: Float): Float = size.width / 2 - width(y) / 2 + width(y) * edge / n
lanes.forEachIndexed { i, lane ->
if (!lane.recommended()) return@forEachIndexed
drawPath(
Path().apply {
moveTo(x(i.toFloat(), 0f), 0f)
lineTo(x(i + 1f, 0f), 0f)
lineTo(x(i + 1f, h), h)
lineTo(x(i.toFloat(), h), h)
close()
},
fill,
)
}
val edge = 2.dp.toPx()
drawLine(line, Offset(x(0f, 0f), 0f), Offset(x(0f, h), h), edge)
drawLine(line, Offset(x(n.toFloat(), 0f), 0f), Offset(x(n.toFloat(), h), h), edge)
val dashes = PathEffect.dashPathEffect(floatArrayOf(h * 0.22f, h * 0.14f))
for (i in 1 until n) {
drawLine(lerp(ground, line, 0.7f), Offset(x(i.toFloat(), 0f), 0f), Offset(x(i.toFloat(), h), h), edge, pathEffect = dashes)
}
val top = h * 0.1f
val bottom = h * 0.9f
val half = minOf(bottomWidth / n * 0.34f, h * 0.4f)
lanes.forEachIndexed { i, lane ->
// A point [u] across from the lane's middle, measured as at the bottom, at height [y].
val onRoad = { u: Float, y: Float -> Offset(x(i + 0.5f, y) + u * width(y) / bottomWidth, y) }
val stroke = half * 0.24f * width((top + bottom) / 2) / bottomWidth
val directions = lane.directions.orEmpty().ifEmpty { listOf(null) }
val behind = if (lane.recommended()) fill else ground
val on = if (lane.recommended()) onFill else line
// Solid colours, faded by mixing rather than transparency, so where a stem and a head
// overlap nothing shows through twice. Faded first, so the one to take draws on top.
directions.sortedBy { it?.isRecommended == true }.forEach { d ->
val color = if (d?.isRecommended == true) on else lerp(behind, on, 0.38f)
laneArrow(d?.shape ?: LaneDirection.SHAPE_STRAIGHT, onRoad, top, bottom, half, stroke, color)
}
}
}
}
/**
* One direction's arrow inside a lane: a stem up from [bottom] that carries on straight, bends
* off to a side, or loops back for a U-turn, with a head at the end. Laid out flat, [half] being
* half the room across the lane, then put on the road by [onRoad]. A lane's arrows share a stem.
*/
private fun DrawScope.laneArrow(
shape: Int,
onRoad: (Float, Float) -> Offset,
top: Float,
bottom: Float,
half: Float,
stroke: Float,
color: Color,
) {
val head = half * 0.24f * 1.9f
val path = Path()
fun Path.to(p: Offset) = onRoad(p.x, p.y).let { lineTo(it.x, it.y) }
fun Path.curve(c: Offset, p: Offset) {
val a = onRoad(c.x, c.y)
val b = onRoad(p.x, p.y)
quadraticTo(a.x, a.y, b.x, b.y)
}
onRoad(0f, bottom).let { path.moveTo(it.x, it.y) }
val tip: Offset
val dir: Offset
val degrees = when (shape) {
LaneDirection.SHAPE_SLIGHT_LEFT -> -40f
LaneDirection.SHAPE_SLIGHT_RIGHT -> 40f
LaneDirection.SHAPE_NORMAL_LEFT -> -90f
LaneDirection.SHAPE_NORMAL_RIGHT -> 90f
LaneDirection.SHAPE_SHARP_LEFT -> -135f
LaneDirection.SHAPE_SHARP_RIGHT -> 135f
LaneDirection.SHAPE_U_TURN_LEFT -> -180f
LaneDirection.SHAPE_U_TURN_RIGHT -> 180f
else -> 0f
}
val side = if (degrees < 0) -1f else 1f
when {
degrees == 0f -> {
path.to(Offset(0f, top + head))
tip = Offset(0f, top)
dir = Offset(0f, -1f)
}
abs(degrees) == 180f -> {
// Up, over the top and back down beside the stem.
val r = half * 0.45f
val back = side * 2 * r
path.to(Offset(0f, top + r))
path.curve(Offset(0f, top), Offset(side * r, top))
path.curve(Offset(back, top), Offset(back, top + r))
val end = top + (bottom - top) * 0.55f
path.to(Offset(back, end))
tip = Offset(back, end + head)
dir = Offset(0f, 1f)
}
else -> {
val rad = degrees / 180f * PI.toFloat()
val d = Offset(sin(rad), -cos(rad))
val fork = top + (bottom - top) * 0.5f
val length = minOf((bottom - top) * 0.42f, (half - head) / abs(d.x))
val bend = Offset(0f, fork - length * 0.45f)
val end = bend + d * length
path.to(Offset(0f, fork))
path.curve(bend, end)
tip = end + d * head
dir = d
}
}
drawPath(path, color, style = Stroke(stroke, cap = StrokeCap.Round, join = StrokeJoin.Round))
val across = Offset(-dir.y, dir.x) * (head * 0.85f)
val base = tip - dir * (head * 1.6f)
val corners = listOf(tip, base + across, base - across).map { onRoad(it.x, it.y) }
drawPath(
Path().apply {
moveTo(corners[0].x, corners[0].y)
lineTo(corners[1].x, corners[1].y)
lineTo(corners[2].x, corners[2].y)
close()
},
color,
)
}
/**
* Which lanes to be in, in words, when the recommended ones sit together: the left or right
* lanes, the middle one, a lane counted from the nearer side, or any. Null when nothing is
* recommended or the lanes to use aren't next to each other.
*/
@Composable
internal fun laneHint(lanes: List<Lane>): String? {
val n = lanes.size
val use = lanes.indices.filter { lanes[it].recommended() }
if (n < 2 || use.isEmpty()) return null
if (use.size == n) return stringResource(R.string.nav_lane_any)
if (use.last() - use.first() + 1 != use.size) return null
val resources = LocalContext.current.resources
val k = use.size
val only = use.first()
return when {
only == 0 -> resources.getQuantityString(R.plurals.nav_lane_left, k, k)
use.last() == n - 1 -> resources.getQuantityString(R.plurals.nav_lane_right, k, k)
k == 1 && n % 2 == 1 && only == n / 2 -> stringResource(R.string.nav_lane_middle)
k == 1 && only < n - 1 - only -> stringResource(R.string.nav_lane_from_left, only + 1)
k == 1 -> stringResource(R.string.nav_lane_from_right, n - only)
else -> stringResource(R.string.nav_lane_middle_lanes)
}
}
private fun Lane.recommended(): Boolean = directions.orEmpty().any { it.isRecommended }
@@ -30,7 +30,6 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -39,8 +38,8 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.layoutId
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
@@ -49,8 +48,6 @@ import androidx.compose.ui.unit.dp
import androidx.car.app.model.CarIcon
import androidx.car.app.model.Template
import androidx.car.app.model.PlaceListMapTemplate
import androidx.car.app.navigation.model.Lane
import androidx.car.app.navigation.model.LaneDirection
import androidx.car.app.navigation.model.MapTemplate
import androidx.car.app.navigation.model.MapWithContentTemplate
import androidx.car.app.navigation.model.NavigationTemplate
@@ -228,8 +225,11 @@ private fun NavigationBars(
Modifier.width(column).fillMaxHeight().covers("nav-cards", cardEdge).padding(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
routing?.let { RoutingCard(it, Modifier.fillMaxWidth()) }
Spacer(Modifier.weight(1f))
// The estimate always gets its full height; the turn card has what's left and
// keeps its top, the part that matters, if it ever runs out of room.
Box(Modifier.weight(1f).fillMaxWidth().clip(RoundedCornerShape(16.dp))) {
routing?.let { RoutingCard(it, Modifier.fillMaxWidth()) }
}
estimate?.let { TravelEstimateCard(it, Modifier.fillMaxWidth()) }
}
}
@@ -267,9 +267,10 @@ private fun RoutingCard(info: RoutingInfo, modifier: Modifier = Modifier) {
val step = info.currentStep ?: return
val scheme = MaterialTheme.colorScheme
ChromeSurface(modifier) {
Column {
// On a short screen the hint goes first, then the next turn, then the lanes; never the turn.
FitColumn {
Column(
Modifier.fillMaxWidth().background(scheme.primaryContainer).padding(16.dp),
Modifier.layoutId(0).fillMaxWidth().background(scheme.primaryContainer).padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
androidx.compose.runtime.CompositionLocalProvider(
@@ -302,16 +303,22 @@ private fun RoutingCard(info: RoutingInfo, modifier: Modifier = Modifier) {
overflow = TextOverflow.Ellipsis,
)
}
if (step.lanesImage != null || step.lanes.orEmpty().isNotEmpty()) {
LaneGuidance(step, Modifier.fillMaxWidth().heightIn(max = 56.dp))
// An app that sends only its own picture of the lanes gets it shown as it is.
if (step.lanes.orEmpty().isEmpty()) {
step.lanesImage?.let { JunctionImage(it, Modifier.heightIn(max = 56.dp)) }
}
// Rare, and only where an arrow can't explain the junction: worth the card growing for.
info.junctionImage?.let { JunctionImage(it) }
}
}
// Drawn from the lane data whenever there is some, even beside the app's own picture.
step.lanes.orEmpty().takeIf { it.isNotEmpty() }?.let {
LaneBand(it, Modifier.layoutId(1).fillMaxWidth())
LaneHintLine(it, Modifier.layoutId(3).fillMaxWidth())
}
info.nextStep?.let { next ->
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
Modifier.layoutId(2).fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(stringResource(R.string.nav_then), style = ChromeType.label, color = scheme.onSurfaceVariant)
@@ -328,6 +335,39 @@ private fun RoutingCard(info: RoutingInfo, modifier: Modifier = Modifier) {
}
}
/**
* A column that leaves whole parts out when they don't all fit, rather than cutting the last one
* in half. Each part's layoutId is its rank: the lowest is kept first and always shown, then each
* next one if there's still room for it. What's kept stays in its written order.
*/
@Composable
private fun FitColumn(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
androidx.compose.ui.layout.Layout(content, modifier) { measurables, constraints ->
val loose = constraints.copy(minHeight = 0, maxHeight = androidx.compose.ui.unit.Constraints.Infinity)
val parts = measurables.map { it.measure(loose) }
val ranks = measurables.map { it.layoutId as? Int ?: 0 }
val kept = BooleanArray(parts.size)
var room = if (constraints.hasBoundedHeight) constraints.maxHeight else Int.MAX_VALUE
parts.indices.sortedBy { ranks[it] }.forEachIndexed { order, i ->
if (order == 0 || parts[i].height <= room) {
kept[i] = true
room -= parts[i].height
}
}
val width = (parts.maxOfOrNull { it.width } ?: 0).coerceIn(constraints.minWidth, constraints.maxWidth)
val height = parts.indices.filter { kept[it] }.sumOf { parts[it].height }
layout(width, height.coerceAtLeast(constraints.minHeight)) {
var y = 0
parts.forEachIndexed { i, part ->
if (kept[i]) {
part.place(0, y)
y += part.height
}
}
}
}
}
/** The zoomed-in intersection an app draws for a junction too complex for an arrow to explain. */
@Composable
private fun JunctionImage(icon: CarIcon, modifier: Modifier = Modifier) {
@@ -340,63 +380,6 @@ private fun JunctionImage(icon: CarIcon, modifier: Modifier = Modifier) {
)
}
/**
* Which lane to be in before the manoeuvre, either as the app's own picture ([Step.getLanesImage])
* or as a row of arrows built from its structured lane data - whichever it sent. The recommended
* lane, or lanes, stand out in the theme's own colour; the rest sit on the same muted, solid tile
* everything else uses, never dimmed by transparency.
*/
@Composable
private fun LaneGuidance(step: Step, modifier: Modifier = Modifier) {
step.lanesImage?.let {
JunctionImage(it, modifier)
return
}
val lanes = step.lanes.orEmpty()
if (lanes.isEmpty()) return
Row(modifier, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
lanes.forEach { lane -> LaneTile(lane) }
}
}
@Composable
private fun LaneTile(lane: Lane) {
val directions = lane.directions.orEmpty()
val shown = directions.firstOrNull { it.isRecommended } ?: directions.firstOrNull()
val recommended = shown?.isRecommended == true
Box(
Modifier
.size(36.dp)
.clip(RoundedCornerShape(8.dp))
.background(
if (recommended) MaterialTheme.colorScheme.primaryContainer
else MaterialTheme.colorScheme.surfaceContainerHighest,
),
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Filled.KeyboardArrowUp,
contentDescription = stringResource(if (recommended) R.string.nav_lane_recommended else R.string.nav_lane),
tint = if (recommended) MaterialTheme.colorScheme.onPrimaryContainer
else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(24.dp).rotate(shown.turnDegrees()),
)
}
}
/** The one arrow glyph this app draws, turned to stand in for every shape a lane can permit. */
private fun LaneDirection?.turnDegrees(): Float = when (this?.shape) {
LaneDirection.SHAPE_SLIGHT_RIGHT -> 30f
LaneDirection.SHAPE_NORMAL_RIGHT -> 90f
LaneDirection.SHAPE_SHARP_RIGHT -> 135f
LaneDirection.SHAPE_U_TURN_RIGHT -> 170f
LaneDirection.SHAPE_SLIGHT_LEFT -> -30f
LaneDirection.SHAPE_NORMAL_LEFT -> -90f
LaneDirection.SHAPE_SHARP_LEFT -> -135f
LaneDirection.SHAPE_U_TURN_LEFT -> -170f
else -> 0f
}
/**
* The trip at a glance, stacked: time left on top, then the clock time it ends at and the
* distance, with Gearslip's own stop button beside them. The close button is Gearslip's, not the
+13
View File
@@ -737,6 +737,19 @@
<!-- -->
<string name="nav_lane_recommended">Recommended lane</string>
<string name="nav_lane">Lane</string>
<plurals name="nav_lane_left">
<item quantity="one">Use the left lane</item>
<item quantity="other">Use the left %d lanes</item>
</plurals>
<plurals name="nav_lane_right">
<item quantity="one">Use the right lane</item>
<item quantity="other">Use the right %d lanes</item>
</plurals>
<string name="nav_lane_middle">Use the middle lane</string>
<string name="nav_lane_middle_lanes">Use the middle lanes</string>
<string name="nav_lane_from_left">Use lane %1$d from the left</string>
<string name="nav_lane_from_right">Use lane %1$d from the right</string>
<string name="nav_lane_any">Any lane</string>
<string name="nav_arrive">Arrive %1$s</string>
<!-- Car app screens -->