nav bar: can hide while navigating, or until the screen is tapped

Set in car Settings. Off "Always show", the bar floats over the map, so
hiding it never resizes the map app's surface; the app is told the bar
covers the bottom while it shows. The arrival card no longer counts as
covering the map, so Waze's speedometer sits level with it.
This commit is contained in:
Seb3thehacker committed 2026-10-16 13:51:44 -07:00
1 parent 08ce09ad61
commit 325e115752
7 files changed
+98 -15

No files matched your search

@@ -62,6 +62,7 @@ fun CarAppStage(
val template by connection.template.collectAsStateWithLifecycle() val template by connection.template.collectAsStateWithLifecycle()
val cover = remember { ChromeCover() } val cover = remember { ChromeCover() }
val navBar = LocalNavBarInset.current
Box(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize()) {
// The app's own drawing goes underneath; everything else is the host's rendering of // The app's own drawing goes underneath; everything else is the host's rendering of
@@ -70,7 +71,11 @@ fun CarAppStage(
if (status.surfaceRequested) { if (status.surfaceRequested) {
AppSurface(connection, frame, template, Modifier.fillMaxSize()) AppSurface(connection, frame, template, Modifier.fillMaxSize())
} }
TemplateChrome(template, Modifier.fillMaxSize().padding(top = if (embedded) 0.dp else CHROME_BAR)) TemplateChrome(
template,
Modifier.fillMaxSize().padding(top = if (embedded) 0.dp else CHROME_BAR, bottom = navBar),
)
BottomCover(navBar, Modifier.align(Alignment.BottomCenter))
} }
if (!embedded) ChromeBar(status, onDisconnect, Modifier.align(Alignment.TopCenter)) if (!embedded) ChromeBar(status, onDisconnect, Modifier.align(Alignment.TopCenter))
} }
@@ -104,7 +104,7 @@ fun CarHome() {
MapPane(navStatus, frame) MapPane(navStatus, frame)
} }
if (splitScreen) { if (splitScreen) {
val side = Modifier.weight(1f).fillMaxHeight() val side = Modifier.weight(1f).fillMaxHeight().padding(bottom = LocalNavBarInset.current)
if (panel == SidePanel.LYRICS) SideLyrics(now, side) else DockedPlayer(now, side) if (panel == SidePanel.LYRICS) SideLyrics(now, side) else DockedPlayer(now, side)
} }
} }
@@ -18,6 +18,9 @@ enum class Units { AUTO, IMPERIAL, METRIC }
/** The car UI's own colours. Separate from [NightMode], which is about the light outside (the map). */ /** The car UI's own colours. Separate from [NightMode], which is about the light outside (the map). */
enum class AppTheme { PHONE, LIGHT, DARK } enum class AppTheme { PHONE, LIGHT, DARK }
/** When the nav bar gets out of the way: never, during a route, or whenever the map is up. */
enum class NavBarMode { SHOW, HIDE_NAVIGATING, HIDE }
/** /**
* Settings changed from the car screen itself. Held as flows so the car UI reacts the moment a * Settings changed from the car screen itself. Held as flows so the car UI reacts the moment a
* button is tapped, and persisted through [AppSettings] so they survive the next connection. * button is tapped, and persisted through [AppSettings] so they survive the next connection.
@@ -45,6 +48,7 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
private const val KEY_CAR_SENSORS = "car_sensors_enabled" private const val KEY_CAR_SENSORS = "car_sensors_enabled"
private const val KEY_SEEN_SAFETY_WARNING = "car_seen_safety_warning" private const val KEY_SEEN_SAFETY_WARNING = "car_seen_safety_warning"
private const val KEY_UNITS = "car_units" private const val KEY_UNITS = "car_units"
private const val KEY_NAV_BAR = "car_nav_bar"
private const val KEY_WHATS_NEW = "car_whats_new_after_updates" private const val KEY_WHATS_NEW = "car_whats_new_after_updates"
private const val KEY_WHATS_NEW_SEEN = "car_whats_new_seen_code" private const val KEY_WHATS_NEW_SEEN = "car_whats_new_seen_code"
@@ -84,6 +88,14 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
val units: StateFlow<Units> = unitsFlow val units: StateFlow<Units> = unitsFlow
private val navBarFlow = MutableStateFlow(NavBarMode.SHOW)
val navBarMode: StateFlow<NavBarMode> = navBarFlow
fun setNavBarMode(mode: NavBarMode) {
navBarFlow.value = mode
AppSettings.putString(app, KEY_NAV_BAR, mode.name)
}
private val whatsNewFlow = MutableStateFlow(true) private val whatsNewFlow = MutableStateFlow(true)
private val whatsNewSeenFlow = MutableStateFlow(0) private val whatsNewSeenFlow = MutableStateFlow(0)
@@ -243,6 +255,9 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
unitsFlow.value = runCatching { unitsFlow.value = runCatching {
Units.valueOf(AppSettings.getString(app, KEY_UNITS, Units.AUTO.name)) Units.valueOf(AppSettings.getString(app, KEY_UNITS, Units.AUTO.name))
}.getOrDefault(Units.AUTO) }.getOrDefault(Units.AUTO)
navBarFlow.value = runCatching {
NavBarMode.valueOf(AppSettings.getString(app, KEY_NAV_BAR, NavBarMode.SHOW.name))
}.getOrDefault(NavBarMode.SHOW)
whatsNewFlow.value = AppSettings.getString(app, KEY_WHATS_NEW, "true") == "true" whatsNewFlow.value = AppSettings.getString(app, KEY_WHATS_NEW, "true") == "true"
whatsNewSeenFlow.value = AppSettings.getString(app, KEY_WHATS_NEW_SEEN, "0").toIntOrNull() ?: 0 whatsNewSeenFlow.value = AppSettings.getString(app, KEY_WHATS_NEW_SEEN, "0").toIntOrNull() ?: 0
} }
@@ -51,6 +51,7 @@ fun CarSettingsScreen() {
val open by CarSettings.openOnConnect.collectAsState() val open by CarSettings.openOnConnect.collectAsState()
val night by CarSettings.nightMode.collectAsState() val night by CarSettings.nightMode.collectAsState()
val units by CarSettings.units.collectAsState() val units by CarSettings.units.collectAsState()
val navBar by CarSettings.navBarMode.collectAsState()
val whatsNew by CarSettings.whatsNewAfterUpdates.collectAsState() val whatsNew by CarSettings.whatsNewAfterUpdates.collectAsState()
val pipeAudio by CarSettings.pipeAudio.collectAsState() val pipeAudio by CarSettings.pipeAudio.collectAsState()
val speakDirections by CarSettings.speakDirections.collectAsState() val speakDirections by CarSettings.speakDirections.collectAsState()
@@ -115,6 +116,16 @@ fun CarSettingsScreen() {
units, units,
) { CarSettings.setUnits(it) } ) { CarSettings.setUnits(it) }
ChoiceRow(
"Nav bar",
listOf(
NavBarMode.SHOW to "Always show",
NavBarMode.HIDE_NAVIGATING to "Hide while navigating",
NavBarMode.HIDE to "Hide until tapped",
),
navBar,
) { CarSettings.setNavBarMode(it) }
ChoiceRow( ChoiceRow(
"Player in the nav bar", "Player in the nav bar",
listOf(false to "Skip buttons", true to "Song title"), listOf(false to "Skip buttons", true to "Song title"),
@@ -211,6 +211,22 @@ fun CarUi() {
} }
val screen = navigator.current val screen = navigator.current
val safetyWarningSeen by CarSettings.hasSeenSafetyWarning.collectAsState() val safetyWarningSeen by CarSettings.hasSeenSafetyWarning.collectAsState()
val keyboardVisible by CarKeyboardVisibility.state
// Off "Always show", the bar floats over the map rather than taking a row of its
// own: hiding it then leaves the map's surface the size it was. Resizing it would
// hand the map app a new surface each time, and some go black on that.
val navBarMode by CarSettings.navBarMode.collectAsState()
val appTemplate by CarServices.nav.template.collectAsState()
val floatingBar = navBarMode != NavBarMode.SHOW
val barMayHide = screen == CarScreen.Home && when (navBarMode) {
NavBarMode.SHOW -> false
NavBarMode.HIDE_NAVIGATING -> appTemplate is androidx.car.app.navigation.model.NavigationTemplate
NavBarMode.HIDE -> true
}
// A full wait from when hiding becomes possible, not from whenever the last touch was.
LaunchedEffect(barMayHide) { if (barMayHide) controlsWake.touch() }
val barShown = !keyboardVisible && (!barMayHide || controlsWake.navBarAwake)
val homeInset = if (floatingBar && barShown) NAV_BAR_HEIGHT else 0.dp
Box(Modifier.fillMaxSize().padding(pad)) { Box(Modifier.fillMaxSize().padding(pad)) {
Column(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize()) {
if (!safetyWarningSeen) { if (!safetyWarningSeen) {
@@ -223,12 +239,14 @@ fun CarUi() {
// Home never leaves the composition; other screens cover it. Dropping it // Home never leaves the composition; other screens cover it. Dropping it
// would tear down the map's surface, and coming back would then show black // would tear down the map's surface, and coming back would then show black
// while the nav app redrew on a new one. // while the nav app redrew on a new one.
CarHome() CompositionLocalProvider(LocalNavBarInset provides homeInset) { CarHome() }
if (screen != CarScreen.Home) { if (screen != CarScreen.Home) {
Box( Box(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.background(MaterialTheme.colorScheme.background) .background(MaterialTheme.colorScheme.background)
// The floating bar never hides here, so keep the screen clear of it.
.padding(bottom = if (floatingBar && !keyboardVisible) NAV_BAR_HEIGHT else 0.dp)
// Keeps touches that miss this screen's controls off the map. // Keeps touches that miss this screen's controls off the map.
.pointerInput(Unit) { detectTapGestures { } }, .pointerInput(Unit) { detectTapGestures { } },
) { ) {
@@ -247,7 +265,7 @@ fun CarUi() {
} }
} }
} }
CarToastOverlay(Modifier.align(Alignment.BottomCenter)) CarToastOverlay(Modifier.align(Alignment.BottomCenter).padding(bottom = homeInset))
CallPromptOverlay() CallPromptOverlay()
BluetoothPairOverlay() BluetoothPairOverlay()
PopupTimer() PopupTimer()
@@ -259,20 +277,27 @@ fun CarUi() {
// Above the popup: a call is more urgent than any notification. // Above the popup: a call is more urgent than any notification.
VoiceHelpOverlay(Modifier.align(Alignment.Center)) VoiceHelpOverlay(Modifier.align(Alignment.Center))
CallOverlay(Modifier.align(Alignment.TopCenter)) CallOverlay(Modifier.align(Alignment.TopCenter))
AssistantOverlay(Modifier.align(Alignment.BottomCenter)) AssistantOverlay(Modifier.align(Alignment.BottomCenter).padding(bottom = homeInset))
if (app.seb3thehacker.gearslip.BuildConfig.DEBUG) { if (app.seb3thehacker.gearslip.BuildConfig.DEBUG) {
// Bottom corner, not top: a screen's own header almost always puts // Bottom corner, not top: a screen's own header almost always puts
// something in the top-right (settings, search, a Liked Songs // something in the top-right (settings, search, a Liked Songs
// shortcut) and this badge used to sit right on top of it, hiding // shortcut) and this badge used to sit right on top of it, hiding
// whatever the screen drew there. // whatever the screen drew there.
DebugBuildBadge(Modifier.align(Alignment.BottomEnd)) DebugBuildBadge(Modifier.align(Alignment.BottomEnd).padding(bottom = homeInset))
}
if (floatingBar) {
androidx.compose.animation.AnimatedVisibility(
barShown,
Modifier.align(Alignment.BottomCenter),
enter = androidx.compose.animation.slideInVertically { it },
exit = androidx.compose.animation.slideOutVertically { it },
) { CarNavBar(navigator) }
} }
} }
// The keyboard takes the nav bar's row instead of squeezing in above it - // The keyboard takes the nav bar's row instead of squeezing in above it -
// nothing on the nav bar is useful mid-type, and the screen is too small to // nothing on the nav bar is useful mid-type, and the screen is too small to
// show both. // show both.
val keyboardVisible by CarKeyboardVisibility.state if (!floatingBar && !keyboardVisible) CarNavBar(navigator)
if (!keyboardVisible) CarNavBar(navigator)
} }
// Over everything, nav bar included: a long press on an app, anywhere. // Over everything, nav bar included: a long press on an app, anywhere.
AppMenu(navigator, rememberRunningApps()) AppMenu(navigator, rememberRunningApps())
@@ -284,6 +309,14 @@ fun CarUi() {
} }
} }
private val NAV_BAR_HEIGHT = 56.dp
/**
* How much of the bottom of Home the floating nav bar covers right now: its height while it shows,
* zero while it's hidden or when it has a row of its own.
*/
internal val LocalNavBarInset = androidx.compose.runtime.compositionLocalOf { 0.dp }
/** /**
* A single blocking screen, shown once per install before anything else the car screen can show - * A single blocking screen, shown once per install before anything else the car screen can show -
* Home included. The phone's own setup guide carries the same warning, but a driver who plugs in * Home included. The phone's own setup guide carries the same warning, but a driver who plugs in
@@ -462,7 +495,7 @@ private fun CarNavBar(navigator: CarNavigator) {
val running = rememberRunningApps() val running = rememberRunningApps()
Surface(color = MaterialTheme.colorScheme.surfaceContainer) { Surface(color = MaterialTheme.colorScheme.surfaceContainer) {
BoxWithConstraints(Modifier.fillMaxWidth().height(56.dp).padding(horizontal = 10.dp)) { BoxWithConstraints(Modifier.fillMaxWidth().height(NAV_BAR_HEIGHT).padding(horizontal = 10.dp)) {
Row(Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) { Row(Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) {
// Home and Apps are two different places now, not a toggle on one button - the // Home and Apps are two different places now, not a toggle on one button - the
// breadcrumb trail is how you retrace your steps, but reaching for either of // breadcrumb trail is how you retrace your steps, but reaching for either of
@@ -11,27 +11,35 @@ import android.os.SystemClock
* *
* They fade out after [IDLE_MS] with no touch anywhere on the car screen, to leave the map clear, * They fade out after [IDLE_MS] with no touch anywhere on the car screen, to leave the map clear,
* and any touch brings them back. [touch] is called for every pointer event and is cheap: it only * and any touch brings them back. [touch] is called for every pointer event and is cheap: it only
* writes state when the buttons were actually hidden. * writes state when the buttons were actually hidden. The nav bar, when set to hide, goes the
* same way but waits longer ([NAV_BAR_IDLE_MS]).
*/ */
class MapControlsWake { class MapControlsWake {
var awake by mutableStateOf(true) var awake by mutableStateOf(true)
private set private set
var navBarAwake by mutableStateOf(true)
private set
private var lastTouch = SystemClock.uptimeMillis() private var lastTouch = SystemClock.uptimeMillis()
fun touch() { fun touch() {
lastTouch = SystemClock.uptimeMillis() lastTouch = SystemClock.uptimeMillis()
if (!awake) awake = true if (!awake) awake = true
if (!navBarAwake) navBarAwake = true
} }
/** Called on a timer; hides the buttons once nothing has touched the screen for [IDLE_MS]. */ /** Called on a timer; hides the buttons once nothing has touched the screen for [IDLE_MS]. */
fun tick() { fun tick() {
if (awake && SystemClock.uptimeMillis() - lastTouch >= IDLE_MS) awake = false val idle = SystemClock.uptimeMillis() - lastTouch
if (awake && idle >= IDLE_MS) awake = false
if (navBarAwake && idle >= NAV_BAR_IDLE_MS) navBarAwake = false
} }
companion object { companion object {
const val IDLE_MS = 4_000L const val IDLE_MS = 4_000L
const val NAV_BAR_IDLE_MS = 8_000L
} }
} }
@@ -103,7 +103,7 @@ fun visibleAreaFor(template: Template?, width: Int, height: Int, measured: Rect?
is NavigationTemplate -> measured ?: Rect(0, 0, width, height) is NavigationTemplate -> measured ?: Rect(0, 0, width, height)
is MapWithContentTemplate, is MapTemplate, is PlaceListNavigationTemplate, is MapWithContentTemplate, is MapTemplate, is PlaceListNavigationTemplate,
is RoutePreviewNavigationTemplate, is PlaceListMapTemplate, is RoutePreviewNavigationTemplate, is PlaceListMapTemplate,
-> Rect((width * CONTENT_PANE_FRACTION).toInt(), 0, width, height) -> Rect((width * CONTENT_PANE_FRACTION).toInt(), 0, width, measured?.bottom ?: height)
null -> Rect(0, 0, width, height) null -> Rect(0, 0, width, height)
else -> Rect(0, 0, 0, 0) else -> Rect(0, 0, 0, 0)
} }
@@ -150,9 +150,18 @@ internal class ChromeCover {
internal val LocalChromeCover = compositionLocalOf<ChromeCover?> { null } internal val LocalChromeCover = compositionLocalOf<ChromeCover?> { null }
/**
* The floating nav bar, as far as the app's map is concerned. Its full [height] at once rather
* than following the slide, so the app is told once instead of every frame.
*/
@Composable
internal fun BottomCover(height: androidx.compose.ui.unit.Dp, modifier: Modifier = Modifier) {
Box(modifier.fillMaxWidth().height(height).covers("nav-bar", CoverEdge.BOTTOM))
}
/** Records where this lands, padding included, until it leaves the screen. */ /** Records where this lands, padding included, until it leaves the screen. */
@Composable @Composable
private fun Modifier.covers(key: String, edge: CoverEdge): Modifier { internal fun Modifier.covers(key: String, edge: CoverEdge): Modifier {
val cover = LocalChromeCover.current ?: return this val cover = LocalChromeCover.current ?: return this
DisposableEffect(cover, key) { onDispose { cover.remove(key) } } DisposableEffect(cover, key) { onDispose { cover.remove(key) } }
return onGloballyPositioned { cover.put(key, edge, it.boundsInWindow()) } return onGloballyPositioned { cover.put(key, edge, it.boundsInWindow()) }
@@ -171,7 +180,9 @@ private fun NavigationChrome(template: NavigationTemplate, modifier: Modifier) {
/** /**
* The turn in one short bar across the top, the arrival time in another along the bottom, so the * The turn in one short bar across the top, the arrival time in another along the bottom, so the
* map keeps its full width. Neither grows past [NAV_BAR_MAX_WIDTH]. The map buttons sit between them on the right. * map keeps its full width. Neither grows past [NAV_BAR_MAX_WIDTH]. The bottom one is short and
* left out of the visible area on purpose, so a speedometer in the app's bottom corner sits level
* with it rather than stacked above it. The map buttons sit between them on the right.
*/ */
@Composable @Composable
private fun NavigationBars( private fun NavigationBars(
@@ -194,7 +205,7 @@ private fun NavigationBars(
estimate?.let { estimate?.let {
TravelEstimateBar( TravelEstimateBar(
it, it,
Modifier.align(Alignment.BottomStart).covers("nav-bottom", CoverEdge.BOTTOM).padding(12.dp) Modifier.align(Alignment.BottomStart).padding(12.dp)
.widthIn(max = NAV_BAR_MAX_WIDTH), .widthIn(max = NAV_BAR_MAX_WIDTH),
) )
} }